Compare commits
122
Commits
0.1
...
b02419dee3
No files matched your search
@@ -1 +1,2 @@
|
||||
build/
|
||||
__pycache__/
|
||||
@@ -1,100 +0,0 @@
|
||||
# Final Fixes Report
|
||||
|
||||
## Fix 1 — `known_sources()` round-trip check
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
||||
|
||||
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
||||
|
||||
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
||||
|
||||
## Fix 3 — false comment on empty-list placeholder
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
||||
|
||||
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
||||
|
||||
## Fix 5 — cross-reference comment in `save_layout()`
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
||||
|
||||
## Fix 6 — include ordering
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
||||
|
||||
## Fix 7 — CLAUDE.md probe text
|
||||
**File:** `CLAUDE.md`
|
||||
|
||||
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
||||
|
||||
## Fix 8 — dead-port run must not overwrite a configured session
|
||||
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
||||
|
||||
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Build
|
||||
```
|
||||
cmake --build build
|
||||
```
|
||||
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
||||
|
||||
### 2. `/tmp/check_sources` (two new assertions added)
|
||||
```
|
||||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
||||
/tmp/check_sources
|
||||
```
|
||||
Output:
|
||||
```
|
||||
a session with no named signal is not listed ok
|
||||
newest first ok
|
||||
oldest last ok
|
||||
the stored group comes back ok
|
||||
the row and signal counts are the file's ok
|
||||
the filename splits at the last underscore ok
|
||||
forget_source reports success ok
|
||||
forget_source removes exactly one ok
|
||||
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
||||
_8000.toml is not listed (empty host does not round-trip) ok
|
||||
all ok
|
||||
```
|
||||
|
||||
### 3. `/tmp/check_layout_rt`
|
||||
```
|
||||
/tmp/check_layout_rt
|
||||
```
|
||||
Output: all 11 assertions `ok`.
|
||||
|
||||
### 4. End-to-end chooser + scope run
|
||||
```
|
||||
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
||||
```
|
||||
Relevant output:
|
||||
```
|
||||
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||||
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
||||
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
||||
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
||||
```
|
||||
|
||||
### 5. Fix 8 dead-port assertion
|
||||
```
|
||||
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
```
|
||||
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
||||
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
||||
@@ -78,6 +78,55 @@ add_custom_command(
|
||||
COMMENT "Compiling udpscope help resources"
|
||||
VERBATIM)
|
||||
|
||||
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
||||
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
|
||||
# in rather than looked for at runtime -- and `preprocess="xml-stripblanks"` makes a malformed one a
|
||||
# build failure with a file and line number, instead of a window that silently refuses to open.
|
||||
set(UDPSCOPE_UI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/ui)
|
||||
file(GLOB UDPSCOPE_UI_FILES CONFIGURE_DEPENDS ${UDPSCOPE_UI_DIR}/*.ui)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
--sourcedir=${UDPSCOPE_UI_DIR}
|
||||
--c-name=udpscope_ui
|
||||
--generate-source
|
||||
${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml
|
||||
DEPENDS ${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml ${UDPSCOPE_UI_FILES}
|
||||
COMMENT "Compiling udpscope UI resources"
|
||||
VERBATIM)
|
||||
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
||||
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
||||
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and
|
||||
# help bundles are small enough per file never to need it), so the build stays at its four warnings.
|
||||
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||
|
||||
# --- the UI files: an opt-in check, never part of `cmake --build` ---------------
|
||||
# The interface is hand-written GtkBuilder XML, and nothing in the build sees a mistake in it: a
|
||||
# property a class does not have is a `Gtk-ERROR` when the window is built, and `Gtk-ERROR` *aborts
|
||||
# the process* -- so a typo takes the application down rather than failing to open a dialog.
|
||||
# `xml-stripblanks` catches malformed XML and nothing else, and the probes catch the rest only for the
|
||||
# windows they open.
|
||||
#
|
||||
# blueprint-compiler type-checks every property and child type against the GIR, and it reads XML as
|
||||
# well as blueprint (`decompile` turns one into the other), so `tools/check_ui.py` round-trips each
|
||||
# file through it. The XML stays the source of truth and stays hand-written; nothing here is needed to
|
||||
# build the application, which is the whole point of not converting the files.
|
||||
#
|
||||
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||
# blueprint-compiler is not a build dependency.
|
||||
find_program(UDPSCOPE_PYTHON NAMES python3)
|
||||
if(UDPSCOPE_PYTHON)
|
||||
add_custom_target(test-ui
|
||||
COMMAND ${UDPSCOPE_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py ${UDPSCOPE_UI_DIR}
|
||||
DEPENDS ${UDPSCOPE_UI_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py
|
||||
COMMENT "Checking the UI files with blueprint-compiler"
|
||||
VERBATIM)
|
||||
else()
|
||||
message(WARNING "python3 was not found: the test-ui target is not available")
|
||||
endif()
|
||||
|
||||
# --- build info: version, commit and timestamp compiled into the binary -------
|
||||
# Written at configure time so add_executable can name the file, and again on every
|
||||
# build so the commit and the timestamp belong to the binary that is running rather
|
||||
@@ -100,15 +149,21 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
||||
add_executable(udpscope
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||
${UDPSCOPE_BUILD_INFO}
|
||||
src/main.cpp
|
||||
src/config.cpp
|
||||
src/channel.cpp
|
||||
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
|
||||
src/search.cpp
|
||||
src/layout_store.cpp
|
||||
src/source_dialog.cpp
|
||||
src/measure.cpp
|
||||
@@ -116,7 +171,19 @@ add_executable(udpscope
|
||||
src/status_text.cpp
|
||||
src/shortcuts.cpp
|
||||
src/help_window.cpp
|
||||
src/settings.cpp
|
||||
src/ui_prefs.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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A GTK4 oscilloscope-style viewer for a UDPStreamer source: it receives one stream
|
||||
with [`deps/udpsclient`](deps/udpsclient), buffers it in two identical large
|
||||
circular buffers, and draws it with [`deps/plotter`](deps/plotter).
|
||||
circular buffers, and draws it with [`deps/plotter`](https://git.ferrarinas.it/martino/plotter).
|
||||
|
||||
`SPECS.md` is the requirement document; this file describes what was built.
|
||||
|
||||
@@ -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).
|
||||
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
|
||||
@@ -201,17 +202,58 @@ measurement.
|
||||
|
||||
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
|
||||
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
|
||||
only make that count wrong — a split near either extreme counts noise as crossings, and
|
||||
counting both edges halves the period.
|
||||
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
|
||||
to reject noise with, and counting both edges halves the period.
|
||||
|
||||
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
||||
signal in this window, and a level in volts would have to be typed for every signal. A
|
||||
crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal
|
||||
that merely touches the level does not count every sample as an event.
|
||||
signal in this window, and a level in volts would have to be typed for every signal.
|
||||
|
||||
### Why a crossing is counted with hysteresis
|
||||
|
||||
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
||||
the window's peak-to-peak either side of the split, and an edge is counted only once the signal
|
||||
has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and
|
||||
it is the difference between counting the waveform's cycles and counting its noise.
|
||||
|
||||
Counting on one level was the first implementation and it fails on anything real, badly and
|
||||
silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there
|
||||
is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any
|
||||
interferer faster than the fundamental recrosses the level several times per true crossing, and
|
||||
each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||
amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving
|
||||
`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely
|
||||
noisy, it is a different order. With the band the same window reads `period=0.200188`. The band
|
||||
holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`,
|
||||
and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
|
||||
|
||||
Three things about it are deliberate, and each was wrong first.
|
||||
|
||||
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
|
||||
level, because that split *is* `top` and `bottom` — the mean of the samples above and below the
|
||||
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still
|
||||
be `0.636620`, 2/π.
|
||||
- **The band is clamped to stay inside the data**, to half the room between the level and the
|
||||
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
|
||||
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
|
||||
threshold *above every sample in the window*: the walk never decides the upper side and the
|
||||
measurement reads nothing at all, which is strictly worse than reading something noisy. Measured
|
||||
on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with
|
||||
the clamp.
|
||||
- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold
|
||||
condition to `level ± h` would refuse every bucket lying inside the band — and those are
|
||||
precisely the buckets a crossing passes through, so the raw walk would get a *share of the
|
||||
window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always
|
||||
be binned; only a side *change* whose position is inside the bucket is refused. The clean fast
|
||||
mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the
|
||||
0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's.
|
||||
|
||||
A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one
|
||||
does not count every sample as an event.
|
||||
|
||||
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
|
||||
the plot) and the signal's chip, in the palette colour every other view names that signal
|
||||
with. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
the plot) and the signal's chip, in the colour every other view names that signal with —
|
||||
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
|
||||
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
|
||||
line says what would be drawn.
|
||||
|
||||
@@ -258,18 +300,20 @@ Both walks go through it:
|
||||
the window's size. The two stamps the scan needed are read at the end rather than at every
|
||||
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
|
||||
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
|
||||
folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample
|
||||
in it decides the same side, none is *at* the level, and an entering edge is exactly the
|
||||
bucket's first sample, which is where the raw walk would have found it. Only the buckets that
|
||||
**straddle** a level are opened, which on a clean waveform is one per crossing.
|
||||
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
|
||||
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
|
||||
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
|
||||
holds that crossing at its first sample, exactly, which is where the raw walk would have found
|
||||
it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is
|
||||
opened — on a clean waveform a bucket or two per crossing.
|
||||
|
||||
The raw path is still there and still exact, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
|
||||
for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads
|
||||
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
|
||||
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
|
||||
noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else
|
||||
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
||||
noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
|
||||
else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
||||
this: `raw` near `covered` means the pyramid bought nothing.
|
||||
|
||||
### What the math channels cost
|
||||
@@ -300,17 +344,298 @@ array of per-node structs was measured and bought **nothing** — at six nodes t
|
||||
L1-resident either way. It was kept because a node's state belonging on one cache line is the
|
||||
right shape for a graph that grows, not because it showed up.
|
||||
|
||||
### Statistics
|
||||
|
||||
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
|
||||
mean, the standard deviation, and the range. The observations are the pane's own **recomputes**
|
||||
— one per refresh, not one per sample — so `n` is a count of *windows measured*, which is what
|
||||
makes "the mean frequency over the last four hundred windows" the claim it reads as. The pane
|
||||
refreshes at most ten times a second while the window scrolls and exactly once when it settles,
|
||||
so a settled window contributes one observation and then stops.
|
||||
|
||||
The accumulator is Welford's, not a sum of squares: a measurement whose values sit at 1e9 with
|
||||
a spread of 1e-3 is an ordinary case here (an absolute stream time is one), and the naive form
|
||||
loses that spread entirely.
|
||||
|
||||
**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
|
||||
that renames the signals, and a change of the display window — each of those changes *what is
|
||||
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
|
||||
latch, a pan and a zoom do **not** reset: they change which samples are in view, which is
|
||||
exactly what a statistic over many windows is meant to average. The toggle itself does not
|
||||
reset either, so turning the line off to read the plot and back on again keeps the history.
|
||||
|
||||
## Resequencing and relative timing
|
||||
|
||||
**A shaded band marks samples the stream says exist and have not arrived.** When a packet is
|
||||
received that reveals a gap in the counter, a run of NaN samples is reserved for the missing
|
||||
packets; the span is shaded in the plot and the counters in the status bar show it. The band
|
||||
carries three states:
|
||||
|
||||
- **Solid** while the hole still lacks packets.
|
||||
- **Faint** once the late packets arrive and repair the values — the data is now correct, but
|
||||
the record remains that this region arrived out of order.
|
||||
- **Absent** once that part of the stream scrolls out of the ring's buffer.
|
||||
|
||||
Two things are **not** repaired when a late packet arrives: **math channels** and **the trigger**.
|
||||
A backfilled slot's value is corrected in the ring and in the pyramid, so the raw trace and the
|
||||
readouts are right. The math engine and the trigger already ran over the placeholder, however, and
|
||||
are not replayed: a math trace keeps its gap across a repaired hole, and if the trigger latched
|
||||
adjacent to one, that latch was on the data the engine actually saw.
|
||||
|
||||
**Known limitations.** The fade is all-or-nothing at the hole level — a hole with one packet still
|
||||
missing shades solidly, even if all but one slot are already repaired. And the two tints are not
|
||||
yet configurable; the preferences dialog in `todo.md` is where that would land. Do not use a
|
||||
command-line flag for either; none exists.
|
||||
|
||||
**`--relative-time` makes the x axis follow the sample count and the declared rate**, rather than
|
||||
the stream's own clock. The packet counter says exactly how many samples each gap spans, so a
|
||||
reserved hole is a precise arithmetic range. In stream mode the same hole is estimated from the
|
||||
declared cadence, which is the existing glitch-bridge mechanism reused.
|
||||
|
||||
The history cost of relative mode is measured rather than computed:
|
||||
|
||||
| mode | retained span |
|
||||
| --- | --- |
|
||||
| stream time (default) | **83.886 s** |
|
||||
| `--relative-time` | **41.943 s** |
|
||||
|
||||
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
|
||||
Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
|
||||
not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second
|
||||
f64 ring for absolute time beside the stream clock's, so a tick's cost goes from
|
||||
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
|
||||
floored to a **power of two** (`src/model.cpp:83`, `:110`), so a third more weight does not cost a
|
||||
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/<host>-<port>/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/events/192.168.1.40-8000/
|
||||
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
|
||||
`<host>_<port>.toml.imported` rather than deleted, so the import can be undone by hand.
|
||||
|
||||
## Preferences
|
||||
|
||||
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
|
||||
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
|
||||
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
|
||||
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
|
||||
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
|
||||
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
|
||||
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
|
||||
chosen. A change
|
||||
applies as it is made — there is no Apply button, and a **Reset to defaults** row at the end of the
|
||||
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
|
||||
subtitle under it where the setting needs explaining, and the control on the right. A key that is
|
||||
absent means its default, so the file only mentions what changed, and a missing file is a first run
|
||||
rather than an error. The directory field shows the directory in use: the default
|
||||
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
|
||||
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
|
||||
button beside it, which writes what it found into the same field -- the field stays the one value, and
|
||||
there is nothing to keep in step.
|
||||
|
||||
The same dialog is reachable from the source chooser, the one window that exists before a run is
|
||||
planned. From the app the cache directory and relative time take effect on the next start; from the
|
||||
chooser they reach the run about to start. A preference applied there also beats a source's stored
|
||||
configuration, exactly as `--relative-time` does: it is the more recent statement of intent, and it
|
||||
works in both directions, so turning relative time off in the dialog is not undone by the row you click
|
||||
afterwards.
|
||||
## The session layout file
|
||||
|
||||
What you arranged is remembered per data source, in
|
||||
`~/.config/udpscope/<host>_<port>.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:
|
||||
|
||||
```toml
|
||||
window_s = 2
|
||||
plots = 1
|
||||
measure_stats = false
|
||||
|
||||
[[plot]]
|
||||
signals = ["Ch1", "", "", ""]
|
||||
@@ -321,7 +646,8 @@ gain = 2
|
||||
offset = 0.5
|
||||
```
|
||||
|
||||
It is written (400 ms after the last change, and flushed on close) whenever the
|
||||
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
|
||||
`false`). It is written (400 ms after the last change, and flushed on close) whenever the
|
||||
window, the layout, a drop or a calibration changes, and applied once, when the
|
||||
first CONFIG names the signals -- signal *names* are the identity, because a
|
||||
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
|
||||
@@ -417,7 +743,8 @@ ring per channel (N enabled signals -- see the limitation on multi-element signa
|
||||
below). The spec's second buffer is the latched `EventSnapshot`, which is already
|
||||
an immutable copy of the captured window (see "Trigger" below); a second live ring
|
||||
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
|
||||
200 MB.
|
||||
200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
|
||||
"There is one live buffer, not two" under the review findings.
|
||||
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
|
||||
them with the rest of the budget. When a channel's declared sample rate is lower
|
||||
than the fastest channel's, it is decimated by the integer ratio
|
||||
@@ -444,6 +771,37 @@ fallback when the stream provides no usable time signal (it is then the sample's
|
||||
via the arrival estimate); it is never used when the stream's own time signal
|
||||
carries a span.
|
||||
|
||||
**One bad packet is bridged; only a run of them changes the clock.** A real device's
|
||||
clock glitches, and a packet whose stamps jump far ahead of the previous packet's last
|
||||
is a fault rather than a gap — so a jump of more than **256 packet spans** is rejected
|
||||
alongside the three checks that were always there (a stamp that is not finite, a packet
|
||||
with no span at all, a packet that does not advance past the previous one). A rejected
|
||||
packet is **bridged**: its stamps are replaced by a continuation of the accepted timeline
|
||||
at the declared cadence, so the next good packet is weighed against an un-poisoned
|
||||
anchor and is accepted normally. Only **four consecutive** rejected packets abandon the
|
||||
declared time signal for the arrival clock, because that is what a signal which is not a
|
||||
time base at all looks like. The declared unit (`ns`, `us`, `ms`) is resolved *before*
|
||||
these checks rather than after, since they weigh the stamps against a cadence in seconds.
|
||||
|
||||
Believing a glitch instead is what produced *"a big spike in one channel and the
|
||||
acquisition seems to reset"*. The stamps pass through a monotonic clamp, so a glitched
|
||||
value poisoned the mark and pinned every later packet flat at it — the stream froze at one
|
||||
x and never recovered — and the glitch also condemned the *next*, perfectly good packet
|
||||
(now below the poisoned anchor, so it failed the advance test), which threw the whole
|
||||
stream onto the arrival clock. `tools/mock_udpstreamer.py --time-glitch-at <s>
|
||||
--time-glitch-by <s>` injects the fault; with a 10 s hold-off the two latches either side
|
||||
of it are 11.2 s apart, where before the fix they were 1.2 s apart.
|
||||
|
||||
**Switching to the arrival clock does not drop history.** `arrival_epoch_` is derived from
|
||||
the monotonic mark, so the arrival clock *continues* from it and the two timelines share a
|
||||
zero by construction — the recorded stamps are therefore still on one timeline and are
|
||||
kept. They used to be wiped on the reasoning that the old stamps belonged to a dead clock;
|
||||
they do not, and that wipe is what made a single glitching packet look like *"the live
|
||||
stream restarts from 0 with no change from the user"*. The same continuity is why
|
||||
`TriggerEngine` needs no rebase across the switch: it holds four absolute stamps
|
||||
(hold-off, pending edge, last trigger, last sample) and they stay valid. Anything that
|
||||
makes the clock genuinely *discontinuous* would break that and would have to reset them.
|
||||
|
||||
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
|
||||
edge/hold-off/normal-single state machine on the selected channel, and once the
|
||||
requested post-trigger share of the *event span* (the event-window control, not the
|
||||
@@ -533,6 +891,23 @@ after a rebuild — a CONFIG, enabling the signal, or a calibration change all d
|
||||
history — which is exactly when the first event used to latch on an almost empty pre
|
||||
window and draw half a plot.
|
||||
|
||||
**The engine identifies its trigger channel by name, never by index**, and that is what
|
||||
makes the hold-off hold. `TriggerEngine::configure()` drops an open capture when the
|
||||
trigger moves to a different channel, and it is re-called on *every* packet. The config's
|
||||
channel field is an index into the channel list, and that index is not stable: every
|
||||
rebuild of the list renumbers it — enabling a signal that sorts before the trigger's own
|
||||
is enough — and the model then re-resolves the trigger by name and writes the new index
|
||||
back. Keying the reset on the index therefore made any such rebuild abandon the capture
|
||||
**and set no hold-off**, so the next edge inside the post window became t0: with a 1 s
|
||||
window, a 10 % pre share and a 10 s hold-off, an edge 0.6 s into a capture stole it. That
|
||||
needed no glitch and no user action beyond enabling a channel. A deliberate re-point still
|
||||
resets, because the name changes with the source. Since the rebuild also replaces the
|
||||
buffer, the surviving capture's pre side is short and the snapshot reports its own `pre_s`
|
||||
— which may be **negative** when the new ring starts after the edge. That is a faithful
|
||||
reading rather than a defect: `[-pre_s, post_s]` still describes the samples the snapshot
|
||||
holds. `UDPSCOPE_PROBE_TRIGRENUM_MS` drives the whole sequence through the real widgets
|
||||
and prints `t0_held=1` as the assertion.
|
||||
|
||||
The event is published on the sample that latched it, but only after every channel of
|
||||
that sample has reached its ring: the cut is clamped to the shortest value ring, so
|
||||
publishing from the middle of the channel loop dropped the newest sample — the very one
|
||||
@@ -560,6 +935,51 @@ zooming in past the shift showed nothing at all. The self-check now prints `data
|
||||
`drawn=` side by side for the bound trace so the contract is checked rather than
|
||||
assumed.
|
||||
|
||||
### Qualified trigger conditions
|
||||
|
||||
The level-and-edge trigger is one of four shapes the **Shape** dropdown offers, and all four
|
||||
are the *same* trigger: the arming, the hold-off, the pre/post event window and the single/auto
|
||||
modes are unchanged, and only the question "did an event happen at this sample" differs.
|
||||
|
||||
- **edge** — a crossing of the level in the chosen direction. The original behaviour, bit for
|
||||
bit.
|
||||
- **pulse width** — a crossing *and* its return, qualified by how long the excursion lasted:
|
||||
`<`, `>`, `within` or `outside` a pair of widths. The event is timed at the **closing** edge,
|
||||
because that is the first sample at which the width is known. `<` a width shorter than the
|
||||
signal's own pulses is the glitch trigger: nothing but a runt satisfies it.
|
||||
- **window** — the signal entering or leaving a band between two levels. A band is two levels,
|
||||
so the panel refuses a zero-width one: it reduces the condition to 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 — a ±1 square against a band of `[1, 1]` fired 19
|
||||
times in a standalone check. Neither answer is a band, which is why the second spin is
|
||||
clamped an increment away rather than the detector special-cased.
|
||||
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
|
||||
the condition re-arms only when a crossing returns.
|
||||
|
||||
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
||||
width to a window trigger. The condition is **not** persisted in the session file — the session
|
||||
has never persisted the trigger — so a restart comes up on an edge trigger.
|
||||
|
||||
### Searching the buffer
|
||||
|
||||
**Search the buffer**, at the foot of the trigger panel, runs the trigger's *own* condition over
|
||||
the whole retained ring and lists every place it holds. There is no second condition editor: a
|
||||
second place to spell the same question would be a second place to get it wrong.
|
||||
|
||||
The scan runs on the GTK thread in a few milliseconds of work per frame, taking the model's
|
||||
mutex for one chunk at a time, so neither the frame rate nor the producer stalls on it. Up to
|
||||
10 000 hits are kept; past that the scan stops and says so. If the stream overtakes the oldest
|
||||
samples mid-scan the search skips forward to what is still there and reports `the stream
|
||||
overtook the oldest samples` rather than returning a hit at a time that no longer exists.
|
||||
|
||||
The arrows step through the hits. A step **pauses** the view on the hit, centred, exactly as the
|
||||
Pause button does — so the frozen window is pinned and navigable within its own data, and
|
||||
resuming returns to the live view. Up to 16 of the hits **in the visible window** are marked on
|
||||
every row at once, nearest the centre first and the selected one always among them in its own
|
||||
colour; when there are more in view than there are marks the panel says `16 of 38 marks shown —
|
||||
zoom in to see the rest`, which is a true statement about the picture rather than a silent
|
||||
truncation.
|
||||
|
||||
**The live view had two clipping bugs of its own**, both wearing the same face as that
|
||||
one: the older part of the trace blank, the newest part fine, and the crosshair still
|
||||
reading values in the blank part. Neither is a data bug — the samples were always there.
|
||||
@@ -658,20 +1078,36 @@ 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 '<name>': 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; click an output square and then an input square to wire them; drag a box to
|
||||
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle
|
||||
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify.
|
||||
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the
|
||||
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node —
|
||||
the source — so every operation was computed and discarded, and a gain or a filter produced a
|
||||
channel identical to its input. Numeric fields are seeded with their own identity values too, since
|
||||
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
||||
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
|
||||
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
|
||||
background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
|
||||
*fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
|
||||
node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
|
||||
opless placeholder to build past. The graph is a *list* walked in order, so a node can
|
||||
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
|
||||
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
|
||||
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
|
||||
carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot
|
||||
on the canvas says, and settable with *set as output*): it defaulted to the first node — the source
|
||||
— so every operation was computed and discarded, and a gain or a filter produced a channel identical
|
||||
to its input. Numeric fields are seeded with their own identity values too, since
|
||||
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
|
||||
at -1e6, which turned `x*2` into `x*2 - 2000000`.
|
||||
|
||||
Wiring is checked before the channel is created, and the reason is shown rather than the dialog
|
||||
closing on the work.
|
||||
A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
|
||||
while it is non-empty. The two modal alert boxes that used to appear are gone.
|
||||
|
||||
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
|
||||
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
|
||||
@@ -931,14 +1367,109 @@ which hides exactly the spike a capture was triggered on, and since the level wa
|
||||
it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst
|
||||
frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per
|
||||
channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the
|
||||
build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no
|
||||
packet gaps.
|
||||
build runs under `ScopeModel::mutex()`. That stall is now measured rather than estimated —
|
||||
the dump's `publish cost:` line read **115 ms** for one event publish at 1.3 MS/s, against
|
||||
the ~40 ms this paragraph used to quote — and it produced no packet gaps.
|
||||
|
||||
**A paused view carries one too**, and that half was missing until it was found by its
|
||||
*appearance*. `publish_pause_view()` filled `stamps`, `values` and `y` and left `pyramids`
|
||||
empty, so a frozen pause was drawn from every raw sample it held, on every frame — the same
|
||||
defect as above with the same cause, in the other snapshot. `frame path:` read
|
||||
`raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
|
||||
draw mean going 12.34 → 18.18 ms and its worst frame 30.35 → 86.92 ms. But the reason it was
|
||||
noticed is that the trace *looked* different: `Renderer::stage_trace_density()` grades each
|
||||
pixel column against that column's own peak, from a `1/height` dwell deposit per segment, so
|
||||
a column fed several hundred raw inter-sample segments accumulates a far spikier
|
||||
distribution — and a far larger peak to normalise against — than one fed the one or two
|
||||
bucket envelopes the pyramid gives. The paused trace read as a hard bright core with its
|
||||
dwell tail dimmed away. The rule the invariant was written without: **a pause is not a
|
||||
cheaper case than a capture — it freezes the same rings.**
|
||||
|
||||
It is not free, and the trade is one-off against per-frame. Building the pause pyramid took
|
||||
that publish from 6.15 ms to 22.31 ms, and unlike the event publish this one runs on the
|
||||
**UI** thread and so blocks the *producer* — against ~5 ms a frame of draw for as long as the
|
||||
pause lasts, which over a 1.6 s pause at ~60 fps repays thirty times over. The producer
|
||||
absorbs it: an A/B of the same fast-mock run with and without a pause read `gaps=0` both
|
||||
ways, with the pause publish at 86 ms in the paused one. (One earlier run reported `gaps=73`
|
||||
with a pause against 0 without, which is why the stall was worth measuring at all — but it
|
||||
has not reproduced, it did not scale with the publish length, and an 86 ms publish costing
|
||||
nothing is the stronger reading. Treat that 73 as incidental load on one run.)
|
||||
|
||||
The verification that the *look* is restored is the same A/B read through `frame path:`:
|
||||
`raw=0 lod=373 columns=373 graded=373` without a pause against `raw=1 lod=555 columns=555
|
||||
graded=555` with one — the same path, the same 24711-segment worst frame and a segment mean
|
||||
within 5 % — where before the fix the paused run read `raw=21 lod=118`.
|
||||
|
||||
The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame
|
||||
into the tick, the plot's own draw and the whole GTK paint, and report the segment counts
|
||||
and whether the LOD path was taken at all. A segment count in the millions is this defect
|
||||
returning.
|
||||
|
||||
**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a
|
||||
high sample rate, a high signal frequency or on a noisy signal the plots showed strong,
|
||||
*crawling* banding — and three things compounded to produce it, none of which is a bug on its
|
||||
own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `Envelope`
|
||||
emits two points per bucket, so each bucket contributes a near-vertical min→max hairline
|
||||
*and* a near-vertical connector to the next, about 3.6 near-vertical hairlines per column per
|
||||
trace; and every one of them lands at a *fractional* x, because a trace is deliberately not
|
||||
snapped. With src-alpha compositing a column's brightness is then a non-linear function of
|
||||
the sub-pixel phases overlapping it, the phase pattern repeats at the beat between the bucket
|
||||
pitch and the pixel pitch, and a pan sweeps the beat. It needs a *thin ribbon* to be visible:
|
||||
a window whose buckets each span several periods paints a saturated block and hides it
|
||||
entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a
|
||||
whole-window view.
|
||||
|
||||
`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one
|
||||
exactly vertical segment per integer column, from the lowest to the highest screen y any
|
||||
gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace
|
||||
then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream
|
||||
`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint
|
||||
neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as*
|
||||
the stroke, and the gate is the gathered density rather than the LOD level) and for why this
|
||||
is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not.
|
||||
|
||||
It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets).
|
||||
Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame
|
||||
path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames**
|
||||
before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames**
|
||||
after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0
|
||||
staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s
|
||||
`columns=` field is the reading that separates "the segment count collapsed because the
|
||||
reduction ran" from "the window got narrower".
|
||||
|
||||
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
|
||||
is honest about *where* the signal went and silent about *how long it was there*: its min and
|
||||
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
|
||||
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
|
||||
turns on at the first tick, since the renderer does not exist until the area is realized —
|
||||
draws each column graded instead: bright where the signal sits, faint where it only passes
|
||||
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
|
||||
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
|
||||
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
|
||||
rows it spans, and normalisation is against the column's own peak. See the upstream
|
||||
`docs/data-and-performance.md` for the five things that carry it and for the two that were
|
||||
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
|
||||
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
|
||||
exactly where it moves fastest).
|
||||
|
||||
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
|
||||
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
|
||||
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
|
||||
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
|
||||
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
|
||||
drawing the reduction and not a third path, so the two counts agreeing is what says the look
|
||||
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
|
||||
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
|
||||
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
|
||||
per-segment alpha is inexpressible, and each step is its own draw call.
|
||||
|
||||
The one performance trap here is worth stating because only the A/B found it. Asking for a
|
||||
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
|
||||
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
|
||||
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
|
||||
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
|
||||
sub-columns than asked for — and never the path.
|
||||
|
||||
**The panels and the toolbar.** The header bar carries only what you reach for
|
||||
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
||||
side-panel toggle and an overflow menu. Those
|
||||
@@ -1058,6 +1589,20 @@ shape and the column of chips reads as a legend. Because `plot::Plot` fixes a tr
|
||||
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
|
||||
colour change goes through a figure rebuild, which a drop already forces.
|
||||
|
||||
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
|
||||
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
|
||||
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
|
||||
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
|
||||
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
|
||||
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
|
||||
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
|
||||
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
|
||||
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
|
||||
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
|
||||
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
|
||||
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
|
||||
chip cannot become the start of a row drag.
|
||||
|
||||
A `GtkSwitch` and two
|
||||
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
||||
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
|
||||
@@ -1070,10 +1615,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 '<signal>' 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.
|
||||
@@ -1631,7 +2183,16 @@ now — they are listed so they are not mistaken for anything else:
|
||||
latched `EventSnapshot` — an immutable copy of the captured window, which is
|
||||
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
|
||||
also mean acquisition had to follow the trigger state, which is what left the first
|
||||
latch after arming with no pre-trigger history.
|
||||
latch after arming with no pre-trigger history. What a second ring would buy is the
|
||||
publish: freezing a whole `BufferStore` copies nothing and inherits the pyramid the
|
||||
producer already maintains, so both figures in the dump's `publish cost:` line would
|
||||
go to zero. They are worth knowing before pricing that — at 1.3 MS/s an event publish
|
||||
measured **69–115 ms of blocked UI tick** and a pause publish **22–86 ms of blocked
|
||||
producer** — but they do not pay for a second budget's worth of memory, a second
|
||||
per-sample write on the producer, and a single frozen slot where the app today holds
|
||||
an event, a pause and an export's window at once. The *observable* cost is also
|
||||
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
|
||||
what a second ring would buy is a smoother frame at a latch, not recovered packets.
|
||||
- **The buffer is allocated at the first CONFIG, not at initialisation.**
|
||||
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
|
||||
*budget*, and the power-of-two ring rounding means the final
|
||||
@@ -1657,20 +2218,22 @@ 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.
|
||||
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
|
||||
time signal belongs to a signal that is disabled contributes no stream time, and
|
||||
the app falls back to arrival-based timing (with a warning on stderr).
|
||||
- **The choice of time source is sticky, and a change of source empties the rings.**
|
||||
It is decided once (and re-decided on a CONFIG) and then kept: choosing per frame
|
||||
let a stream whose declared time signal fails one check and passes the next
|
||||
alternate between two timelines, so one ring held stamps from both and the x axis,
|
||||
the crosshair and an export read nonsense — a plateau of one timestamp, then a jump
|
||||
to another epoch. On the switch the recorded history is dropped rather than left
|
||||
half on each clock. The stamps themselves are spaced by the stream's *measured*
|
||||
- **The choice of time source is sticky.** It is decided once (and re-decided on a
|
||||
CONFIG) and then kept: choosing per frame let a stream whose declared time signal
|
||||
fails one check and passes the next alternate between two timelines, so one ring held
|
||||
stamps from both and the x axis, the crosshair and an export read nonsense — a plateau
|
||||
of one timestamp, then a jump to another epoch. The switch keeps the recorded history,
|
||||
because the arrival clock is anchored to continue from the monotonic mark and the two
|
||||
timelines therefore share a zero (see "Switching to the arrival clock does not drop
|
||||
history" above); it used to empty the rings, which is a worse reading of the same
|
||||
concern. The stamps themselves are spaced by the stream's *measured*
|
||||
arrival cadence whenever the declared rate disagrees with it, because a CONFIG's
|
||||
rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a
|
||||
hundred samples every fifth of a second made every frame span microseconds while
|
||||
@@ -1706,6 +2269,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.**
|
||||
|
||||
@@ -36,3 +36,18 @@
|
||||
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
||||
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
||||
viewed event, the other used to acquire live data for next event
|
||||
|
||||
## Deviations from the spec
|
||||
|
||||
The following were built differently from what was asked for, each for a reason `README.md` states
|
||||
in full:
|
||||
|
||||
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
|
||||
in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
|
||||
`--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
|
||||
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
|
||||
of two, so a third more weight costs a full halving. The design document and the implementation
|
||||
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
|
||||
reason.
|
||||
Absolute stream time is preserved in a separate ring and used for the export's `abs_seconds`
|
||||
column, exactly as `todo.md` requires.
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: bee318870f...9790becb33.
Vendored
+315
-30
@@ -33,6 +33,13 @@
|
||||
/* Tunable limits */
|
||||
/*---------------------------------------------------------------------------*/
|
||||
|
||||
/** Datagrams held waiting for an earlier counter. */
|
||||
#define UDPS_REORDER_SLOTS 64
|
||||
/** Total bytes the reorder ring may hold, so a fast stream cannot grow it without bound. */
|
||||
#define UDPS_REORDER_BYTES (4u * 1024u * 1024u)
|
||||
/** How long a gap is given to resolve before it is committed, by default. */
|
||||
#define UDPS_REORDER_WINDOW_S_DEFAULT 0.020
|
||||
|
||||
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
||||
#define UDPS_MAX_SLOTS 4
|
||||
|
||||
@@ -41,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
|
||||
@@ -151,6 +158,16 @@ typedef struct {
|
||||
double first_seen;
|
||||
} udps_slot_t;
|
||||
|
||||
/** One slot in the reorder ring: a raw payload held until its counter is next. */
|
||||
typedef struct {
|
||||
uint8_t *buf; /**< owned; grown on demand to the payload's length */
|
||||
size_t cap;
|
||||
size_t len;
|
||||
uint32_t counter;
|
||||
double recv_time;
|
||||
int used;
|
||||
} udps_held_t;
|
||||
|
||||
struct udps_client {
|
||||
udps_client_config_t cfg;
|
||||
/* Owned copies: the caller's strings need not outlive the create call. */
|
||||
@@ -181,12 +198,20 @@ struct udps_client {
|
||||
|
||||
uint8_t *rxbuf;
|
||||
|
||||
uint32_t last_counter;
|
||||
udps_held_t held[UDPS_REORDER_SLOTS];
|
||||
size_t held_bytes; /**< payload bytes currently held */
|
||||
int held_count;
|
||||
uint32_t next_counter; /**< the counter the sequence is waiting for */
|
||||
double gap_deadline; /**< wall time the current gap gives up at; 0 = no gap */
|
||||
|
||||
int have_counter;
|
||||
|
||||
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;
|
||||
@@ -475,43 +500,23 @@ static int decode_config(udps_client_t *c, const uint8_t *pl, size_t len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
/* Decodes and delivers one DATA payload whose ordering has already been resolved
|
||||
* by the caller. `lost` counts how many counters the sequence skipped over
|
||||
* before this packet, and `late` marks it as having lost the race past the
|
||||
* reorder window (so the consumer already has a hole where it belongs). */
|
||||
static int deliver_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time, uint32_t lost, uint8_t late) {
|
||||
uint32_t nsamples = 1u;
|
||||
size_t off = 8u;
|
||||
size_t total = 0u;
|
||||
size_t written = 0u;
|
||||
uint32_t i;
|
||||
uint32_t lost = 0u;
|
||||
udps_frame_t frame;
|
||||
|
||||
if (c->num_sigs == 0u) {
|
||||
return 0; /* DATA before CONFIG: nothing to decode against. */
|
||||
}
|
||||
|
||||
/* Order the sequence before spending anything on the payload.
|
||||
*
|
||||
* Reassembly completes in arrival order, not counter order, so a packet
|
||||
* delayed or duplicated on the wire surfaces after a newer one has already
|
||||
* been delivered. Its samples carry an older time base: they land on top
|
||||
* of data the consumer already has and leave the span they should have
|
||||
* filled empty. Nothing in the payload distinguishes such a packet from a
|
||||
* good one, only the counter does.
|
||||
*
|
||||
* The counter is a wrapping uint32, so it is ordered by the signed
|
||||
* difference; comparing the values directly would call the first packet
|
||||
* after the wrap stale and reject the stream from then on. */
|
||||
if (c->have_counter) {
|
||||
int32_t delta = (int32_t)(counter - c->last_counter);
|
||||
if (delta <= 0) {
|
||||
c->stats.stale_packets++;
|
||||
return 0;
|
||||
}
|
||||
lost = (uint32_t)delta - 1u;
|
||||
c->stats.counter_gaps += lost;
|
||||
}
|
||||
c->last_counter = counter;
|
||||
c->have_counter = 1;
|
||||
if (len < 8u) {
|
||||
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
||||
}
|
||||
@@ -554,10 +559,12 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
}
|
||||
|
||||
c->stats.frames_delivered++;
|
||||
c->stats.counter_gaps += lost;
|
||||
|
||||
if (c->on_data != NULL) {
|
||||
frame.counter = counter;
|
||||
frame.lost = lost;
|
||||
frame.lost = late ? 0u : lost;
|
||||
frame.late = late;
|
||||
frame.hrt = rd_u64(pl);
|
||||
frame.recv_time = recv_time;
|
||||
frame.publish_mode = c->publish_mode;
|
||||
@@ -570,6 +577,245 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Finds a free slot in the reorder ring and stores the payload there.
|
||||
*
|
||||
* A duplicate counter (same datagram arriving twice on the wire) replaces the
|
||||
* existing slot so one counter never consumes two slots and both get delivered.
|
||||
* The search for that slot therefore runs *before* the capacity checks, and the
|
||||
* checks are applied only on the path that needs a new slot: a duplicate at a
|
||||
* full ring consumes nothing and must still be accepted.
|
||||
* Returns non-zero and holds nothing when there is no room (ring full or byte
|
||||
* budget exhausted); `order_data` below says what the caller then does. */
|
||||
static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
int i;
|
||||
int slot = -1;
|
||||
|
||||
/* Duplicate counter: reuse the existing slot to avoid double delivery. */
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (c->held[i].used && c->held[i].counter == counter) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot >= 0) {
|
||||
/* The replacement releases the length already held, so the budget is
|
||||
* weighed against what the slot will cost rather than what it adds. */
|
||||
if (c->held_bytes - c->held[slot].len + len > UDPS_REORDER_BYTES) {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
if (c->held_count >= UDPS_REORDER_SLOTS) {
|
||||
return -1;
|
||||
}
|
||||
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
|
||||
return -1;
|
||||
}
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (!c->held[i].used) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot < 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (ensure_cap((void **)&c->held[slot].buf, &c->held[slot].cap, len) != 0) {
|
||||
return -1;
|
||||
}
|
||||
/* If this is a duplicate replacement, subtract the old length first. */
|
||||
if (c->held[slot].used) {
|
||||
c->held_bytes -= c->held[slot].len;
|
||||
c->held_count--;
|
||||
}
|
||||
memcpy(c->held[slot].buf, pl, len);
|
||||
c->held[slot].len = len;
|
||||
c->held[slot].counter = counter;
|
||||
c->held[slot].recv_time = recv_time;
|
||||
c->held[slot].used = 1;
|
||||
c->held_bytes += len;
|
||||
c->held_count++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Walks the held ring and delivers every entry whose counter matches the
|
||||
* expected sequence, in order. The first delivery carries `first_lost` (so
|
||||
* a caller that committed a gap can report the correct count); every
|
||||
* subsequent delivery in the same drain pass carries lost = 0.
|
||||
*
|
||||
* Clears the gap deadline once the ring empties. */
|
||||
static int drain_held(udps_client_t *c, uint32_t first_lost) {
|
||||
int found;
|
||||
int i;
|
||||
int rc;
|
||||
uint32_t lost;
|
||||
|
||||
lost = first_lost;
|
||||
do {
|
||||
found = 0;
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (c->held[i].used &&
|
||||
(int32_t)(c->held[i].counter - c->next_counter) == 0) {
|
||||
uint32_t cnt = c->held[i].counter;
|
||||
double rt = c->held[i].recv_time;
|
||||
const uint8_t *pl = c->held[i].buf;
|
||||
size_t len = c->held[i].len;
|
||||
/* Release the slot before delivering so deliver_data can call
|
||||
* back into the client without hitting a stale used entry. */
|
||||
c->held_bytes -= len;
|
||||
c->held_count--;
|
||||
c->held[i].used = 0;
|
||||
c->next_counter = cnt + 1u;
|
||||
c->stats.late_resequenced++;
|
||||
rc = deliver_data(c, pl, len, cnt, rt, lost, 0);
|
||||
lost = 0u;
|
||||
if (rc != 0) {
|
||||
return rc;
|
||||
}
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (found);
|
||||
|
||||
if (c->held_count == 0) {
|
||||
c->gap_deadline = 0.0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Gives up on the current gap: finds the lowest held counter (by signed delta,
|
||||
* not raw value, so a wrap mid-gap is handled correctly), advances
|
||||
* next_counter to it, then drains. The first delivery out of that drain
|
||||
* carries the gap's lost count; the rest carry 0.
|
||||
*
|
||||
* With nothing held it just clears the deadline and returns. */
|
||||
static int commit_gap(udps_client_t *c) {
|
||||
int i;
|
||||
int best = -1;
|
||||
uint32_t lost;
|
||||
|
||||
if (c->held_count == 0) {
|
||||
c->gap_deadline = 0.0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
if (!c->held[i].used) {
|
||||
continue;
|
||||
}
|
||||
if (best < 0 ||
|
||||
(int32_t)(c->held[i].counter - c->held[best].counter) < 0) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
lost = (uint32_t)(int32_t)(c->held[best].counter - c->next_counter);
|
||||
c->next_counter = c->held[best].counter;
|
||||
return drain_held(c, lost);
|
||||
}
|
||||
|
||||
/* Orders the sequence before spending anything on the payload.
|
||||
*
|
||||
* The counter is a wrapping uint32, so it is ordered by the signed difference;
|
||||
* comparing the values directly would call the first packet after the wrap stale
|
||||
* and reject the stream from then on.
|
||||
*
|
||||
* Three cases. A packet that *continues* the sequence is decoded now and costs no
|
||||
* latency at all -- that is the common case and it must stay free. A packet that
|
||||
* runs ahead of the sequence means an earlier one is missing, so it is held and the
|
||||
* gap is given `reorder_window_s` to resolve; a reorder on the wire resolves in
|
||||
* milliseconds, and holding is what turns it back into order. A packet behind the
|
||||
* sequence lost that race: the consumer already has a hole where it belongs, so it
|
||||
* is delivered flagged rather than discarded, which is the whole point -- a dropped
|
||||
* packet cannot fill the hole it left.
|
||||
*
|
||||
* The full ring is specified here, in one place. A packet that needs a slot and
|
||||
* finds none gives up on the current gap -- `commit_gap()` delivers the lowest held
|
||||
* run and so frees at least one slot -- counts the event in
|
||||
* `stats.reorder_overflows`, and the decision is then taken again, once, against
|
||||
* the sequence that commit left behind. That re-decision is why this is a bounded
|
||||
* loop and not a straight line: the commit advances `next_counter` to the lowest
|
||||
* held counter, which may put it past the arriving packet, and the packet is then
|
||||
* behind the sequence rather than ahead of it. Whatever happens, the arriving
|
||||
* packet is held or delivered; it is never dropped, because nothing else records it
|
||||
* and the consumer would reserve a hole for it that nothing could ever backfill.
|
||||
* Past the cap it is delivered *in order* and not flagged late: a packet ahead of
|
||||
* the sequence has no reserved hole, the consumer looks a late packet's hole up by
|
||||
* counter and ignores one it cannot place, so advancing `next_counter` to it and
|
||||
* reporting the counters it skipped as `lost` is what reserves their hole. */
|
||||
static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||
uint32_t counter, double recv_time) {
|
||||
int32_t delta;
|
||||
int attempt;
|
||||
int rc;
|
||||
|
||||
if (c->cfg.reorder_window_s <= 0.0) {
|
||||
/* Resequencing off: byte-for-byte the behaviour before this task. `delta`
|
||||
* is measured against the *next* counter rather than the last, which is the
|
||||
* same test shifted by one -- `delta < 0` here is `delta <= 0` there. */
|
||||
uint32_t lost = 0u;
|
||||
if (c->have_counter) {
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta < 0) {
|
||||
c->stats.stale_packets++;
|
||||
return 0;
|
||||
}
|
||||
lost = (uint32_t)delta;
|
||||
}
|
||||
c->have_counter = 1;
|
||||
c->next_counter = counter + 1u;
|
||||
return deliver_data(c, pl, len, counter, recv_time, lost, 0);
|
||||
}
|
||||
if (!c->have_counter) {
|
||||
c->have_counter = 1;
|
||||
c->next_counter = counter + 1u;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||
}
|
||||
for (attempt = 0; attempt < 2; attempt++) {
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta == 0) {
|
||||
rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||
c->next_counter = counter + 1u;
|
||||
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||
return rc;
|
||||
}
|
||||
if (delta < 0) {
|
||||
/* Behind the sequence. */
|
||||
c->stats.late_delivered++;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||
}
|
||||
if (hold_packet(c, pl, len, counter, recv_time) == 0) {
|
||||
if (c->gap_deadline == 0.0) {
|
||||
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
/* No room: give up on the gap rather than growing the ring, and say so
|
||||
* in a counter, so an undersized window is a reading and not a mystery. */
|
||||
c->stats.reorder_overflows++;
|
||||
rc = commit_gap(c);
|
||||
if (rc != 0) {
|
||||
/* The consumer refused a delivery; nothing is freed and nothing more
|
||||
* may be delivered, so the arriving packet is its caller's problem. */
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
/* The attempt cap is spent and the ring still has no room (the byte budget
|
||||
* cannot fit this one even with a slot free). Deliver it rather than drop it. */
|
||||
delta = (int32_t)(counter - c->next_counter);
|
||||
if (delta < 0) {
|
||||
c->stats.late_delivered++;
|
||||
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||
}
|
||||
rc = deliver_data(c, pl, len, counter, recv_time, (uint32_t)delta, 0);
|
||||
c->next_counter = counter + 1u;
|
||||
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||
return rc;
|
||||
}
|
||||
|
||||
/*---------------------------------------------------------------------------*/
|
||||
/* Fragment reassembly */
|
||||
/*---------------------------------------------------------------------------*/
|
||||
@@ -579,6 +825,14 @@ static void slot_reset_all(udps_client_t *c) {
|
||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||
c->slots[i].active = 0;
|
||||
}
|
||||
/* A reconnect starts a new sequence; payloads held from the previous one
|
||||
* must not be delivered into it. */
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
c->held[i].used = 0;
|
||||
}
|
||||
c->held_count = 0;
|
||||
c->held_bytes = 0;
|
||||
c->gap_deadline = 0.0;
|
||||
}
|
||||
|
||||
static void gc_slots(udps_client_t *c, double now) {
|
||||
@@ -596,7 +850,7 @@ static void deliver_payload(udps_client_t *c, uint8_t type, const uint8_t *pl,
|
||||
if (type == UDPS_PKT_CONFIG) {
|
||||
(void)decode_config(c, pl, len);
|
||||
} else {
|
||||
(void)decode_data(c, pl, len, counter, recv_time);
|
||||
(void)order_data(c, pl, len, counter, recv_time);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,6 +1160,7 @@ static int do_connect(udps_client_t *c) {
|
||||
c->last_data = now_mono();
|
||||
c->last_keepalive = c->last_data;
|
||||
c->have_counter = 0;
|
||||
c->next_counter = 0u;
|
||||
slot_reset_all(c);
|
||||
if (c->ever_connected) {
|
||||
c->stats.reconnects++;
|
||||
@@ -981,6 +1236,7 @@ void udps_client_config_init(udps_client_config_t *cfg) {
|
||||
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
||||
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
||||
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
||||
cfg->reorder_window_s = UDPS_REORDER_WINDOW_S_DEFAULT;
|
||||
}
|
||||
|
||||
static void copy_str(char *dst, size_t cap, const char *src) {
|
||||
@@ -992,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;
|
||||
|
||||
@@ -1048,6 +1313,9 @@ void udps_client_destroy(udps_client_t *c) {
|
||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||
free(c->slots[i].payload);
|
||||
}
|
||||
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||
free(c->held[i].buf);
|
||||
}
|
||||
free(c->sigs);
|
||||
free(c->valbuf);
|
||||
free(c->vals);
|
||||
@@ -1151,6 +1419,14 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
||||
}
|
||||
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
||||
|
||||
/* A held packet must flush on time even when nothing further arrives: at 10 pkt/s
|
||||
* the next arrival is 100 ms away, which is five times the whole budget. */
|
||||
if (c->gap_deadline > 0.0) {
|
||||
double left = c->gap_deadline - now_wall();
|
||||
if (left < 0.0) { left = 0.0; }
|
||||
if (left < budget) { budget = left; }
|
||||
}
|
||||
|
||||
FD_ZERO(&rset);
|
||||
maxfd = -1;
|
||||
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
||||
@@ -1204,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);
|
||||
}
|
||||
@@ -1221,6 +1502,10 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
||||
}
|
||||
gc_slots(c, now_wall());
|
||||
|
||||
if (c->gap_deadline > 0.0 && now_wall() >= c->gap_deadline) {
|
||||
(void)commit_gap(c);
|
||||
}
|
||||
|
||||
if (c->cfg.silence_timeout_s > 0.0 &&
|
||||
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
||||
do_disconnect(c, "server went silent");
|
||||
|
||||
Vendored
+59
@@ -150,6 +150,13 @@ typedef struct {
|
||||
* range the next frame claims.
|
||||
*/
|
||||
uint32_t lost;
|
||||
/**
|
||||
* 1 when this frame's counter is behind the sequence already delivered: it lost
|
||||
* the race past the reorder window and the consumer has a hole where it belongs.
|
||||
* @c lost is 0 for such a frame -- it is not missing anything, it *is* what was
|
||||
* missing -- so a consumer sizes its backfill from @c counter, not from @c lost.
|
||||
*/
|
||||
uint8_t late;
|
||||
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
||||
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
||||
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
||||
@@ -180,6 +187,30 @@ typedef struct {
|
||||
* time base older than data already handed over.
|
||||
*/
|
||||
uint64_t stale_packets;
|
||||
/**
|
||||
* DATA packets that were held waiting for an earlier counter and subsequently
|
||||
* delivered via @c drain_held(). This includes a packet released by the
|
||||
* reorder window expiring: that packet arrives alongside a non-zero @c lost
|
||||
* and therefore alongside a gap, so @c late_resequenced is not by itself a
|
||||
* count of gaps avoided. Compare @c late_delivered, which counts packets
|
||||
* that lost the race past an already-expired window entirely.
|
||||
*/
|
||||
uint64_t late_resequenced;
|
||||
/**
|
||||
* DATA packets delivered behind the sequence because the reorder window had
|
||||
* already closed on them. The consumer has a hole for these and may fill it;
|
||||
* before the window existed they were counted in @c stale_packets and dropped.
|
||||
*/
|
||||
uint64_t late_delivered;
|
||||
/**
|
||||
* Times a DATA packet needed a slot in the reorder ring and found none, so the
|
||||
* current gap was committed early to make room. Every such packet is still held
|
||||
* or delivered -- none is dropped -- but the window it was given was shorter
|
||||
* than @c reorder_window_s asked for. A non-zero reading says the ring is
|
||||
* undersized for the stream's reordering depth, which is a reading and not a
|
||||
* mystery; it is the only place that shows up.
|
||||
*/
|
||||
uint64_t reorder_overflows;
|
||||
uint64_t reconnects;
|
||||
} udps_stats_t;
|
||||
|
||||
@@ -206,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.
|
||||
*
|
||||
@@ -226,6 +271,14 @@ typedef struct {
|
||||
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
||||
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
||||
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
||||
/**
|
||||
* How long a packet may be held waiting for an earlier counter, in seconds.
|
||||
*
|
||||
* A *contiguous* arrival is never held, so this is the latency a gap costs and
|
||||
* not a latency the stream pays. 0 disables resequencing entirely and restores
|
||||
* the drop-on-late behaviour. Default 0.020.
|
||||
*/
|
||||
double reorder_window_s;
|
||||
} udps_client_config_t;
|
||||
|
||||
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||
@@ -247,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Architecture: threads, the data path and the layers
|
||||
|
||||
The two threads and what they share, the four display modes, the display clock, and the layers to keep straight.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
|
||||
including deliberate deviations from the spec and known limitations — read it before
|
||||
concluding something is a bug.
|
||||
|
||||
### Threads
|
||||
|
||||
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
|
||||
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
|
||||
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
||||
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
||||
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
||||
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
|
||||
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
|
||||
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
|
||||
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
|
||||
capture open sees which edge it belongs to.
|
||||
|
||||
### Data path
|
||||
|
||||
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
||||
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
||||
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
||||
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
||||
immutable copy of the captured window; see README for why two live rings were dropped.
|
||||
|
||||
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
|
||||
`RowSet::bind()` takes it as an argument rather than recomputing it:
|
||||
|
||||
- `Live` — no trigger: the live ring, on the clock of the **newest sample**
|
||||
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
|
||||
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
|
||||
window is still filling: the live ring, on the coming event's own clock
|
||||
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
|
||||
post side fills in view and the marker sits at x = 0 throughout.
|
||||
- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
|
||||
so the display epoch is 0.
|
||||
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
|
||||
but the axis is still a live one).
|
||||
|
||||
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
|
||||
cuts the window at the newest sample and that partial snapshot becomes the held event, so
|
||||
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
|
||||
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
|
||||
fell back to the *previous* event — or to blank on a first capture. No event fired, so
|
||||
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
|
||||
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
|
||||
distinct: the latch count is what the probes assert "how many events fired" with.
|
||||
|
||||
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
|
||||
be refreshed till next event"), asked for explicitly; that reading is what the old "never
|
||||
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
|
||||
the snapshot *only* — a held event is never quietly replaced by live data — and that the
|
||||
*axis* does not move during a capture: it is the event's window from the edge onwards,
|
||||
never a sliding live view.
|
||||
|
||||
`display_mode()` must be read **once per frame** and passed around (the tick does this,
|
||||
and hands it to `bind()`). The producer can open or close a capture between any two
|
||||
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
|
||||
different clocks.
|
||||
|
||||
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
|
||||
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
|
||||
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
|
||||
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
|
||||
two clocks goes through that one accessor rather than picking the branch itself — the export's
|
||||
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
|
||||
measurement marks — because there are four modes and picking the branch by hand gets one of them
|
||||
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
|
||||
Three things about the live epoch specifically:
|
||||
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
|
||||
sample the *traces* can report, and asking before the acquire is one frame of production
|
||||
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
|
||||
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
|
||||
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
|
||||
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
|
||||
frame rather than converged on.
|
||||
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
|
||||
an epoch of 0 there would make the axis jump on the frame a capture closes.
|
||||
|
||||
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
|
||||
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
|
||||
where the user put them while the waveform rolls under, and a measurement's mark names the sample
|
||||
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
|
||||
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
|
||||
display coordinates would crawl forward at one window per window. `App::measure_window` is
|
||||
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
|
||||
window moved by 2 % of its span", and in display coordinates a live window is a fixed
|
||||
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
|
||||
|
||||
Layers to keep straight:
|
||||
|
||||
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
||||
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
|
||||
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
||||
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
||||
to either a `ChannelBuffer` or an `EventSeries`.
|
||||
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
|
||||
and removed one at a time by the layout editor), each a
|
||||
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
||||
everything the rows have to agree on: the x window (one x link group every row joins),
|
||||
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
||||
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
||||
|
||||
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
||||
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
||||
samples, never the LOD pyramid's buckets.
|
||||
|
||||
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
|
||||
the `tick()` frame function that binds, updates the trigger state and rewrites the
|
||||
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
|
||||
far the largest file.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Build and run
|
||||
|
||||
Configuring, building and launching udpscope, the desktop entry target, the command-line options and the two vendored dependencies.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive # deps/plotter is a submodule
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build
|
||||
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
|
||||
absent, it says so and installs anyway), refreshes `update-desktop-database` and
|
||||
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
|
||||
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
|
||||
touches `$XDG_DATA_HOME`.
|
||||
|
||||
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
|
||||
deleting the build directory breaks it.** That is the price of having no install step and it is
|
||||
deliberate: the entry then always launches exactly the binary that was last built. No
|
||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename is that same string, which is what associates a running window with the entry.
|
||||
|
||||
The interface files can be checked without building anything or opening a window:
|
||||
|
||||
```sh
|
||||
cmake --build build --target test-ui # or: make -C build test-ui
|
||||
```
|
||||
|
||||
That round-trips every `.ui` through `blueprint-compiler` — `decompile` turns it into blueprint, and
|
||||
compiling *that* type-checks every property and child type against the GIR — printing one line per file.
|
||||
It exists because the alternative is what happens otherwise: a property a class does not have is a
|
||||
`Gtk-ERROR` when the window is built, and `Gtk-ERROR` **aborts the process**, so a typo in a `.ui` takes
|
||||
the application down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches
|
||||
malformed XML and nothing else, and the probes catch the rest only for the windows they open.
|
||||
|
||||
The XML stays the source of truth and stays hand-written: this is a check, not a compiler, and nothing
|
||||
here is needed to *build* the application. `blueprint-compiler` and `python3` are found at configure
|
||||
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
||||
prints the linter's advice, which is advice and deliberately not a gate.
|
||||
|
||||
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),
|
||||
`--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. `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
|
||||
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
|
||||
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
|
||||
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
|
||||
application icon as the page's own header. Two consequences worth knowing before editing.
|
||||
`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled
|
||||
the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so
|
||||
**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there
|
||||
reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether
|
||||
the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()`
|
||||
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
|
||||
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
|
||||
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
|
||||
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
|
||||
`deps/udpsclient` is a vendored C99 source pair and its diff from the upstream source
|
||||
is intentional. Five things were changed:
|
||||
|
||||
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
|
||||
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
|
||||
A deadline (`now + reorder_window_s`, default 0.020 s) is set for the gap; the deadline is
|
||||
served by a third `clamp_deadline()` call on the `select()` budget
|
||||
(`udps_client.c:1145-1152`) so a quiet stream does not wait for the next arrival.
|
||||
2. **Late delivery replacing the outright discard.** A packet that arrives past the window — or
|
||||
when the ring is drained by the deadline — is delivered to the callback flagged `late` rather
|
||||
than discarded with `stats.stale_packets++; return 0`. That flag is what makes backfill
|
||||
possible at all; without it the samples are gone before udpscope can act.
|
||||
3. **The third `clamp_deadline()`.** The existing pair handles the keepalive and the silence
|
||||
timeout; the reorder deadline is a third call to the same helper, which already understood
|
||||
the pattern.
|
||||
4. **`reorder_window_s = 0` as the switch.** `udps_client_config_t::reorder_window_s`,
|
||||
defaulted to `UDPS_REORDER_WINDOW_S_DEFAULT` (0.020) in `udps_client_config_init()`,
|
||||
is the only knob. Setting it to zero disables resequencing and restores the previous
|
||||
discard-on-late behaviour exactly — so a reported regression is bisectable without reverting
|
||||
the source. **There is no command-line flag for the reorder window.** It is not surfaced on
|
||||
udpscope's command line; do not document a `--reorder-window-ms`.
|
||||
5. **The full ring never drops the arriving packet, and says so in a counter.** A packet that
|
||||
needs a slot and finds none commits the current gap (which frees at least one, since
|
||||
`commit_gap()` always delivers) and the ordering decision is taken once more against the
|
||||
sequence that commit left behind — a bounded retry, because the commit can advance
|
||||
`next_counter` past the arriving counter and turn an ahead-of-sequence packet into a behind-
|
||||
of-sequence one. Past the retry it is delivered *in order*, with the counters it skipped as
|
||||
its `lost`, and never flagged `late`: a packet ahead of the sequence has no reserved hole and
|
||||
`Acquisition::backfill_from()` silently ignores a late packet it cannot place, so flagging it
|
||||
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
|
||||
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
|
||||
the `[reseq-probe]` line as `overflows=`.
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
common left gutter), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||
band on the *other* side, which the layout had no concept of — `axes` reached the
|
||||
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
|
||||
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
|
||||
adapter `PlotArea`'s **figure mode** —
|
||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||
|
||||
The render-quality work went there for the same reason: `kTextFS` now **filters** the
|
||||
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
|
||||
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
|
||||
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
||||
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
||||
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
||||
when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its
|
||||
unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the
|
||||
whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had —
|
||||
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
|
||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The box-zoom selection was the one gesture with no
|
||||
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
|
||||
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
|
||||
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
|
||||
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
|
||||
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
|
||||
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
|
||||
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
|
||||
the last of which reads the headless framebuffer back and compares the same pixels with and without
|
||||
the frame. The minor
|
||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||
grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`).
|
||||
README records what they cured.
|
||||
|
||||
Changing any of this means committing in the submodule and moving its pin, and running
|
||||
the library's own suite as well as the udpscope probes (see
|
||||
[verification.md](verification.md)):
|
||||
|
||||
```sh
|
||||
cmake --build deps/plotter/build/debug
|
||||
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
|
||||
```
|
||||
@@ -0,0 +1,266 @@
|
||||
# Invariants: the data path — buffers, decimation and the rings
|
||||
|
||||
What rebuilds a buffer, why decimation is an average, and the two rules the time ring's precision and its first stamp impose.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
|
||||
enable/disable, a calibration change, or a **change of display window that moves the
|
||||
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
|
||||
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
|
||||
`time_stride` actually changes, so walking the window inside one decade is free; it asks
|
||||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||||
takes `mutex()` itself — do not call it holding the lock.
|
||||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||||
window too long to store at the wire rate used to be refused (the control was capped to
|
||||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||||
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
|
||||
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
|
||||
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
|
||||
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
|
||||
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
|
||||
same phase and the channel plots flat.
|
||||
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
|
||||
and accumulates *every* sample and pushes the mean of each group of `time_stride`
|
||||
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
|
||||
at the stored rate, so content that would fold back is attenuated instead of reappearing
|
||||
as a false low frequency — which is the whole reason decimation was once dropped
|
||||
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
|
||||
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (the anti-alias check in
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
|
||||
group delay puts the value, and stamping at the end of the group shifts every trace half
|
||||
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
|
||||
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
|
||||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||||
narrower than a group is now filtered away rather than aliased, which is the correct
|
||||
trade and is why the status line's `rate` field is worth reading.
|
||||
- **One bad packet is bridged; only a run of them changes the time source.** The stream-time
|
||||
checks in `build_stamps()` reject three things: stamps that are not finite, a packet with no
|
||||
span at all, and a packet whose stamps do not advance past the previous packet's last. A
|
||||
fourth was added because a real device's clock glitches: a packet whose stamps jump **more
|
||||
than 256 packet spans** ahead is a fault, not a gap, and must be rejected — letting it
|
||||
through poisons `last_stamp_`, and the monotonic clamp (`max(stamps[k] - epoch,
|
||||
last_stamp_)`) then pins every later packet flat at the glitched value, so the whole stream
|
||||
freezes at one x and never recovers. A rejected packet is **bridged**: its stamps are
|
||||
replaced by a continuation of the accepted timeline at the declared cadence, so neither the
|
||||
monotonic mark nor `last_stream_anchor_` is poisoned and the next good packet is accepted
|
||||
normally. Believing the glitch instead is what condemned the *next*, perfectly good packet
|
||||
(it now sits below the poisoned anchor, so it fails the advance test) and threw the whole
|
||||
stream onto the arrival clock. Only **four consecutive** rejected packets abandon the
|
||||
declared time signal, because that is what a signal which is not a time base at all looks
|
||||
like; a first packet with nothing to bridge from still falls back immediately. The unit
|
||||
scale ('ns', 'us', 'ms') is therefore resolved **before** these checks rather than after:
|
||||
they weigh the stamps against a cadence in seconds, and on a 'ns' stream an unscaled
|
||||
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
||||
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
||||
[verification.md](verification.md)).
|
||||
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
||||
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
||||
timelines share a zero by construction — the recorded stamps are therefore still on one
|
||||
timeline and are kept. The fallback used to call `reset_live_history_locked()` on the
|
||||
reasoning that the old stamps belonged to a dead clock; they do not, and that wipe is what
|
||||
made a single glitching packet look like "the live stream restarted from 0 with no action
|
||||
from the user". The continuity is also what makes a rebase of `TriggerEngine` unnecessary:
|
||||
the engine holds four absolute stamps (`holdoff_until_s_`, `pending_start_s_`,
|
||||
`last_trigger_s_`, `last_t_s_`) and they stay valid across the change. Anything that makes
|
||||
the clock *discontinuous* breaks that and has to reset them.
|
||||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||||
is not in the buffer would be cut from data that does not contain it.
|
||||
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
|
||||
A value's precision is a property of the signal (24 bits is plenty for a sample); a
|
||||
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||||
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||||
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||||
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||||
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||||
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||||
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||||
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||||
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||||
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||||
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||||
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||||
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||||
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||||
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||||
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||||
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||||
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||||
value). Three steps at 2, 2.5 and
|
||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||
- **Append is not the only write.** A backfill amends slots that a reservation wrote as NaN, and
|
||||
the amend is split across threads for a reason:
|
||||
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)` is a single relaxed
|
||||
store, bounds-checked against `[produced_ - capacity, produced_)`. The same race class as a
|
||||
push, and an `f32` cannot tear. Measurements and export read the ring directly, so they see
|
||||
a repair immediately and no second notification is needed.
|
||||
- **Pyramid amend — consumer (GTK main thread), inside `ChannelBuffer::acquire()`.** The
|
||||
producer collects amended values in a plain `std::vector<Amend>` member and the consumer
|
||||
drains it after its own append pass. `acquire()` is called under `model.mutex()`, and the
|
||||
producer holds that same mutex for all of `on_data`, so the vector is correctly serialised
|
||||
with no lock-free queue. The reason the pyramid amend cannot also be on the producer: a
|
||||
bucket is a 32-byte non-atomic aggregate that the renderer reads **unlocked at paint time**,
|
||||
so a producer writing it could be caught mid-bucket — a `count` that does not match its
|
||||
`sum`, producing a wrong mean from a structure whose readers trust `count == 0` to mean
|
||||
*absent*. On the consumer that cannot happen.
|
||||
- **A hole is stamped at reservation; backfill changes values only.** The f64 time ring is
|
||||
never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_`
|
||||
must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode,
|
||||
the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late
|
||||
packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap
|
||||
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
|
||||
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
|
||||
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
|
||||
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
|
||||
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
|
||||
describe a buffer that is gone; it therefore clears `holes_` and bumps
|
||||
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
|
||||
and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation
|
||||
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
|
||||
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
|
||||
the truth. The generation is the signal and the stride/channel-count comparison is not: a
|
||||
reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_`
|
||||
is **not** reset — it is relative mode's clock origin, samples since the stream began, and
|
||||
relative time must not jump backwards when the user retypes the window.
|
||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||
of a saturated window routinely point at time slots the producer has already
|
||||
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
|
||||
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
|
||||
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
|
||||
ratio of elapsed stream time to the retained window and clipped the older part of
|
||||
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||||
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||||
*y* value, never the geometry. Clamping buys the same bargain here.
|
||||
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
|
||||
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
|
||||
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
|
||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||||
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||||
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||||
invariant), a local of the renderer's, so the second and third of those are structurally
|
||||
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||||
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||||
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||||
That is the "with measurements and cursors active the plot flickers as
|
||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
|
||||
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
|
||||
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
|
||||
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
|
||||
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
|
||||
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
|
||||
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
|
||||
`trace ink:` / `[ink]` (see [probe-readings.md](probe-readings.md)). The same torn read still exists for a small `i > 0`
|
||||
— 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.
|
||||
|
||||
## There may be no store at all
|
||||
|
||||
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
|
||||
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
|
||||
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
|
||||
button works, and freezing a view means taking a window from the rings), and it crashed inside
|
||||
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
|
||||
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
|
||||
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
|
||||
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
|
||||
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,367 @@
|
||||
# Invariants: GTK traps, the status bar, the icons and the help
|
||||
|
||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The status line's per-second fields are measured once a second, not per frame.**
|
||||
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
||||
tick as the channel dump; the status line only formats what is already there. The
|
||||
badge's markup is likewise rewritten on a change of phase (twice a second), never on
|
||||
all sixty frames.
|
||||
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
|
||||
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
|
||||
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
|
||||
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
|
||||
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
|
||||
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
|
||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
|
||||
into the window entry and picks the unit for it, and the late notify had
|
||||
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
|
||||
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
|
||||
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
|
||||
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
|
||||
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
||||
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
||||
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
||||
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
|
||||
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
|
||||
an internal box), and the cursors pane owns its own
|
||||
**Reset** button rather than the overflow menu owning one for it. The dump prints
|
||||
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
|
||||
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
|
||||
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
|
||||
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
|
||||
one of them with reality): it is computed on the first *tick*, because the dump
|
||||
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.
|
||||
- **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,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
|
||||
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||
cannot read a GResource compiled into the binary).
|
||||
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||
`glib-compile-resources`) and reached through **one**
|
||||
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||
`gdk_display_get_default()` valid there.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||
other.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||
ships with libxml2, which GTK already requires.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an
|
||||
error, it draws `image-missing`.
|
||||
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
|
||||
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
|
||||
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
|
||||
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
|
||||
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
|
||||
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
|
||||
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
|
||||
the one field that is free text. A row added with a parse error is otherwise silent — the
|
||||
overview still opens and the row is simply blank — so read the probe run's stderr for
|
||||
`Failed to parse` after touching either table.
|
||||
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
|
||||
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
|
||||
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
|
||||
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
||||
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
||||
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
||||
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
||||
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
||||
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
||||
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
||||
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
||||
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
||||
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
||||
the same reason, which is what keeps the two tiers from disagreeing.
|
||||
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||
the dump is the assertion.
|
||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
||||
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
||||
the shape that prevents the double-open defect across all three.
|
||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||
configure time (so `add_executable` can name the file) and again on every build through
|
||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
|
||||
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
|
||||
probe reads `config_dir()`, which is the path the session file is written to.
|
||||
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
|
||||
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
|
||||
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
|
||||
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
|
||||
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
||||
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
||||
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
||||
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
||||
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||
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.
|
||||
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live
|
||||
in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
||||
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||
|
||||
`preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on
|
||||
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
|
||||
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
|
||||
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
|
||||
UI file.
|
||||
|
||||
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
|
||||
`cmake --build build --target test-ui` is for: it round-trips every file through
|
||||
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
|
||||
file, rather than for the windows a probe happens to open (see
|
||||
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
|
||||
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
|
||||
application down rather than failing to appear.
|
||||
|
||||
Two more paid for: a `GIcon` property takes an **object**, not a name
|
||||
(`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
||||
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to
|
||||
exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because
|
||||
glib-compile-resources writes each file as one C string literal and the warning fires on generated
|
||||
*data*.
|
||||
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
||||
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
||||
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
||||
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
||||
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
||||
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
||||
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
||||
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
||||
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
||||
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
||||
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
||||
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
||||
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
||||
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
||||
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
||||
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
||||
cancellable will do.
|
||||
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
||||
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
||||
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
||||
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
||||
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
||||
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
||||
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
||||
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
||||
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
||||
draws them only as a titlebar.
|
||||
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
||||
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
||||
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
||||
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
||||
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
||||
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
||||
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
||||
the probe that opens every window rather than by anything at build time.
|
||||
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
||||
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
||||
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
||||
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
||||
means different things on the two sides. Both are plain members of their owners, on the same stack
|
||||
frame, so nothing can outlive the frame that built it.
|
||||
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
||||
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
||||
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
||||
whose handlers point into a returned stack frame is how this project already earned one
|
||||
use-after-free.
|
||||
|
||||
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
|
||||
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
|
||||
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
|
||||
and eleven more titles did in the two preference forms, for as long as they existed. Two different
|
||||
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
|
||||
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
|
||||
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
|
||||
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
|
||||
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
|
||||
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
|
||||
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
|
||||
property against the GIR, inherited ones included.
|
||||
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
|
||||
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
|
||||
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
|
||||
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
|
||||
activated.
|
||||
|
||||
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
|
||||
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
|
||||
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
|
||||
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
|
||||
everything behind an expander, on a panel that is entirely correct; the walk has to start at
|
||||
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
|
||||
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
|
||||
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
|
||||
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
|
||||
way, and it works.
|
||||
|
||||
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
|
||||
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
|
||||
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
|
||||
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
|
||||
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
|
||||
round for one character.
|
||||
|
||||
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
|
||||
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
|
||||
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
|
||||
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
|
||||
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
|
||||
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
|
||||
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
|
||||
|
||||
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
|
||||
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
|
||||
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
|
||||
declares an empty `window_control_slot` box as its first start child and the code appends the control into
|
||||
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
|
||||
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
|
||||
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
|
||||
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
|
||||
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
|
||||
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
|
||||
|
||||
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
|
||||
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
|
||||
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
|
||||
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
|
||||
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
|
||||
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
|
||||
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
|
||||
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
|
||||
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
|
||||
holds.
|
||||
|
||||
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
|
||||
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
|
||||
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
|
||||
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
|
||||
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
|
||||
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
|
||||
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
|
||||
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
|
||||
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
|
||||
|
||||
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
|
||||
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
|
||||
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
|
||||
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
|
||||
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
|
||||
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
|
||||
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
|
||||
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Invariants: the math channels and the node editor
|
||||
|
||||
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
|
||||
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
|
||||
the plot binding, the measurements and the export all handle a derived channel without knowing it
|
||||
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
|
||||
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
|
||||
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
|
||||
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
|
||||
pushed later would be missing from the capture's newest slot). The math therefore runs on the
|
||||
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
|
||||
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
|
||||
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
|
||||
may only read ones *before* it and a cycle is impossible by construction rather than by a check
|
||||
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
|
||||
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
|
||||
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
|
||||
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
|
||||
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
|
||||
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
|
||||
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
|
||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
||||
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
||||
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
||||
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
||||
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
||||
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
||||
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
||||
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
||||
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
||||
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
||||
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
||||
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
||||
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
||||
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
||||
so before the session's graphs) exists.
|
||||
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
||||
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
||||
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
||||
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
||||
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
||||
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
||||
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
||||
which is why the fix is in the push and not in the loader.
|
||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
||||
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
||||
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
||||
channel being edited, so a row that stopped being the one the code writes shows there.
|
||||
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
||||
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
||||
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
||||
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
||||
`math_body` written as a child of the window instead of the box above it. Each line carries
|
||||
`(want ...)` and the reading
|
||||
taken from a real run is the reference:
|
||||
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
||||
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
||||
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
|
||||
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
|
||||
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
|
||||
connection and a direct handler call cannot see it.
|
||||
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
|
||||
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
|
||||
away from the hand. Both deltas must be `0.0000`.
|
||||
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
|
||||
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
|
||||
one is taken after a pan *and* a zoom.
|
||||
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
|
||||
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
|
||||
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
|
||||
pans the view and moves no node.
|
||||
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
|
||||
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
|
||||
`[math-probe] detach: escape restores in_a=0 (want 0)`.
|
||||
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
||||
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
||||
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
||||
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
||||
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
||||
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
||||
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
||||
view.
|
||||
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
|
||||
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
|
||||
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
|
||||
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
||||
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
||||
neither says anything about the UI.
|
||||
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
||||
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
||||
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
||||
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
||||
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
||||
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
||||
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
||||
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
||||
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
||||
written. `MathDialog::syncing` guards it.
|
||||
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
|
||||
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
|
||||
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
|
||||
show is the failure mode.
|
||||
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
|
||||
dropdown lists exactly the acquired channels, not all signals.
|
||||
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
|
||||
`[math-probe] op pick: index round trip=1 (want 1)`.
|
||||
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
||||
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
||||
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
||||
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
||||
assertion, `cells` the reading that explains it.
|
||||
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
||||
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
||||
`overlaps=0` is the assertion.
|
||||
Four rules here were wrong first and are worth keeping:
|
||||
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
|
||||
dialog's dropdown lists, **and both places that create a node must take one from there**.
|
||||
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
|
||||
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
|
||||
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
|
||||
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
|
||||
to what the form is showing, rather than the form showing one channel while the node holds
|
||||
another, and while a source that is not acquired is in the graph the **live validation line is
|
||||
non-empty and Apply is insensitive** (the engine's validator is about the graph -- an output,
|
||||
every input wired above it -- and cannot see the acquired list).
|
||||
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
|
||||
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
|
||||
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
|
||||
ring whose time ring advances once: the values alternated between the real one and 0, and once
|
||||
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
|
||||
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
|
||||
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
|
||||
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
|
||||
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
|
||||
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
|
||||
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
|
||||
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
|
||||
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
|
||||
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
|
||||
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
|
||||
destroy is not a user close, and only one of them runs the handlers.
|
||||
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
|
||||
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
|
||||
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
|
||||
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
||||
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
||||
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
||||
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
||||
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
||||
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
||||
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
||||
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. The
|
||||
probe prints the box the drag *began over* for exactly this reason.
|
||||
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
|
||||
and put the stream's timestamp signal, which has no samples, in it.
|
||||
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
|
||||
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
|
||||
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
|
||||
interval and the filter's state is the old rate's history, so both are rebuilt together.
|
||||
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
|
||||
that is what "I tested a low pass and it was a mess" was.
|
||||
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
|
||||
it.** The output began at index 0 -- the *source* -- so every operation a user built was
|
||||
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
|
||||
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
|
||||
deliberately is kept; this is the default for a graph that has never been given a choice.
|
||||
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
|
||||
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
|
||||
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
|
||||
default is never a useful starting point for a gain or a corner frequency.
|
||||
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
|
||||
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
|
||||
a property of every node.
|
||||
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
|
||||
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
|
||||
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
|
||||
so adding each update's delta to a value the handler then writes back is a runaway box -- it
|
||||
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
|
||||
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
|
||||
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
|
||||
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
|
||||
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
|
||||
that reason.
|
||||
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
|
||||
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
|
||||
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
|
||||
check has to be taken after a pan and a zoom.
|
||||
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
|
||||
Asking the box first makes the output square unreachable, which is the same geometry as the
|
||||
bullet above.
|
||||
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
|
||||
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
|
||||
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
|
||||
order, and until now that made the order the *user's* problem — an earlier node's input was not
|
||||
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
|
||||
so a cycle is found before anything is written, breaks ties by the current list index so a graph
|
||||
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
|
||||
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
|
||||
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
|
||||
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
|
||||
indegree that never reaches zero and so reports a loop that is not there.
|
||||
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
|
||||
selection is exactly the state in which a stale message misleads most: the graph can still be
|
||||
broken and there is no form to read instead.
|
||||
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
||||
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
||||
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
||||
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
||||
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
||||
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
||||
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
||||
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
||||
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
||||
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
||||
and the inner call rewrote the label the outer one had just written.
|
||||
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
|
||||
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
|
||||
two happen to agree today. The session-file rule in this file already says an op is written as
|
||||
its index through `math_op_at()`; this is the other direction of the same rule.
|
||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||
accident; the `delete node` button stays the only way to remove a node.
|
||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
||||
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
||||
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
||||
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
||||
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
||||
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
||||
counting the slots cost more than the work being counted. The counters are producer-written
|
||||
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
||||
friends). Two things about the figure itself:
|
||||
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
||||
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
||||
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
||||
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
||||
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
||||
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
||||
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
||||
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
||||
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
||||
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
||||
`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 '<name>': 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.
|
||||
|
||||
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
||||
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
||||
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
||||
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
||||
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
||||
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
||||
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
||||
which is the "modifying one expression invalidates all the others" report.
|
||||
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
||||
while the edit runs -- and never 0.
|
||||
|
||||
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
|
||||
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
|
||||
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
|
||||
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
|
||||
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
|
||||
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
|
||||
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
|
||||
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Invariants: the measurements pane
|
||||
|
||||
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
|
||||
frame's.** A measurement is a statistic of the visible window and a live view moves that window
|
||||
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
|
||||
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
|
||||
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
|
||||
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
|
||||
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
|
||||
when the window settles and once per mode change -- and reading *that* backwards is what made
|
||||
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
|
||||
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
||||
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
||||
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
||||
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
|
||||
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
|
||||
`stats` argument is for.
|
||||
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
||||
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
||||
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
||||
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
|
||||
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
|
||||
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
|
||||
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
|
||||
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
|
||||
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
|
||||
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
|
||||
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
|
||||
that window and may not. Both are emitted in **one pass after the numbers, never inside the
|
||||
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
|
||||
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
|
||||
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
|
||||
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
|
||||
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
|
||||
clamp.
|
||||
- **A measurement must not read its window raw.** Both passes go through
|
||||
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
|
||||
pane inherits the summary of the new samples rather than computing one, which is what the
|
||||
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
|
||||
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
|
||||
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
|
||||
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
|
||||
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
|
||||
to a sample, two buckets at most whatever the window's size. Pass two
|
||||
(`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one
|
||||
side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and
|
||||
none is *at* the level, so the fold is exact and not an approximation. It asks **separately**
|
||||
whether the bucket enters a side of the hysteresis band (see the invariant below), because a
|
||||
single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket
|
||||
wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw
|
||||
walk would have found it. It refuses a bucket that straddles the level, one that enters a side
|
||||
somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first
|
||||
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
|
||||
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
|
||||
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
|
||||
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
|
||||
be. Four things this rests on, each of which breaks it silently:
|
||||
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
|
||||
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
|
||||
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
|
||||
window offset, and a mismatch there measures the wrong samples rather than failing.
|
||||
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
|
||||
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
|
||||
still be right.
|
||||
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
|
||||
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
|
||||
the pyramid for nothing.
|
||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||
anything.
|
||||
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
||||
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
||||
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
||||
nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real --
|
||||
the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the
|
||||
fundamental recrosses a single level several times per true crossing. Measured on a 5 Hz sine with
|
||||
a 500 Hz tone at a tenth of the amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true
|
||||
crossings counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against
|
||||
`period=0.200188` with the band. Four rules, each of which was wrong first:
|
||||
* **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather
|
||||
than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket
|
||||
path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi -
|
||||
level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2
|
||||
(`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05.
|
||||
* **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and
|
||||
a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never
|
||||
decides that side and the measurement reads nothing at all, which is worse than reading
|
||||
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
|
||||
`enters=4 period=0.200000` with the clamp.
|
||||
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
|
||||
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
|
||||
unit sine must still be `0.636620`.
|
||||
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
|
||||
band is a parameter of the engine rather than a mode of it.
|
||||
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
|
||||
1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A
|
||||
`/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I
|
||||
src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty
|
||||
squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same
|
||||
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
|
||||
that the fast path is a fast path and not an approximation. The app's reading is
|
||||
`[measures-probe]` against `--noise 0.1`.
|
||||
- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
|
||||
a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
|
||||
settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
|
||||
Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
|
||||
not what any of these measurements measure: a frequency over a window is not a property of a
|
||||
sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
|
||||
than the tens.
|
||||
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
|
||||
measurement's values are routinely large with a small spread — an absolute stream time is the
|
||||
obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the
|
||||
spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
|
||||
- **What resets the statistics is "what is being measured changed", not "what is in view
|
||||
changed".** An explicit Reset, an add/edit/delete (all three funnel through
|
||||
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
|
||||
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
|
||||
not: they change which samples answer it, which is precisely what averaging over many windows
|
||||
is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
|
||||
always created and always fed, so reading the plot for a moment does not cost the history.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Invariants: the plot, the figure and the layout
|
||||
|
||||
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
|
||||
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
|
||||
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
|
||||
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
|
||||
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
|
||||
from the paned's own width, re-parenting the column, the property form of its children, size requests):
|
||||
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
|
||||
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
|
||||
`max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set
|
||||
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
|
||||
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly
|
||||
the case that used to be zero.
|
||||
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
|
||||
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
|
||||
it back as the user's wish is wrong: doing that made connecting a source leave the sidebar shut, because
|
||||
the widget's own `false` was recorded as the user asking for it. A `GBinding` from the property to the
|
||||
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
|
||||
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
|
||||
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
|
||||
`set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals"
|
||||
into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees —
|
||||
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
|
||||
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
|
||||
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
|
||||
`sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently
|
||||
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
|
||||
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
|
||||
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
|
||||
*function's order* rather than about the window's shape, which is why it is the one part of the
|
||||
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
|
||||
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
|
||||
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
|
||||
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
||||
taller figure.
|
||||
|
||||
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
||||
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
||||
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
||||
removed.
|
||||
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
|
||||
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
||||
state. That makes one path for "a row's traces changed" and "the row count changed",
|
||||
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
||||
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
|
||||
would otherwise throw the frozen view onto the stream, where nothing moves it off
|
||||
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
||||
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||
destroys every row.
|
||||
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
|
||||
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
|
||||
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
|
||||
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
|
||||
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
|
||||
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
|
||||
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
|
||||
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
|
||||
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
|
||||
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
|
||||
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
|
||||
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
|
||||
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
|
||||
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
|
||||
session file was re-saved every session for ever. The refusal also heals an existing file on
|
||||
the next save. How the names first got in is unexplained — the drop path already refuses a
|
||||
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
|
||||
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
|
||||
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
|
||||
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
|
||||
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
|
||||
than renumbering the rows under the user. `carry_row()` deliberately copies fields
|
||||
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
|
||||
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
|
||||
to the row's *position*. It clears the destination's slot bindings for the same reason —
|
||||
the next `bind()` re-resolves them, and without the clear the frame in between draws the
|
||||
moved row's samples under the new row's legend.
|
||||
Both call `rebuild_all()` themselves, so their callers must re-point the area
|
||||
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
|
||||
a *dirty* row.
|
||||
- **The layout editor's buttons are *separate* overlay children, never one transparent
|
||||
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
|
||||
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
|
||||
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
|
||||
in. Each button is therefore a small widget aligned to a corner or the centre and placed
|
||||
by its **margins**, and the overlay is only measured from its main child, so moving them
|
||||
cannot reach the plot's layout.
|
||||
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
|
||||
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
|
||||
margin is logical ones. Reading the figure's own rects rather than dividing the widget
|
||||
height by the row count is what keeps a `×` on the row it names when the rows stop being
|
||||
an equal division. `sync_layout_editor()` runs every frame on purpose:
|
||||
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
|
||||
the comparisons and nothing else, and a resize needs no notification.
|
||||
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
||||
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
||||
trace at another signal.
|
||||
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
|
||||
figures and a one-glyph prefix, measured and not counted in characters — and only ever
|
||||
grows; the status label is ellipsized so
|
||||
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
|
||||
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
|
||||
of the gutter for a label no stream produces, and the floor being a monotone maximum over
|
||||
the *measured* need means one that did appear widens the gutter for good anyway. Erring
|
||||
wide is not free here — it is a third of the plot's width.
|
||||
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
|
||||
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
|
||||
back — so a constant added to it compounds. See the ratchet reading under `layout over N frames` in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
The same rule is why the activity badge is one glyph in three colours rather than
|
||||
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||
letting the counter grow: each is a status field whose *width* would otherwise depend
|
||||
on the stream, and the status row is directly above the plot.
|
||||
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||
rows survives exactly until the next layout.
|
||||
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
|
||||
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
|
||||
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
|
||||
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
|
||||
same one-shot block). Half a label is the figure that matters: an x tick label is
|
||||
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
|
||||
on the axes' right edge, and with `axes` flush to the viewport half of that label was
|
||||
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
|
||||
to end on one column as well as start on one — which is why `apply_gutters()` applies the
|
||||
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
|
||||
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
|
||||
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
|
||||
signal dropped on two rows is one colour and the signal panel's chip names a trace
|
||||
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
|
||||
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
||||
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
||||
same height.
|
||||
- **The invalid-span band is data-anchored and reserves no layout space.** `Plot::set_invalid_spans()`
|
||||
draws a tinted quad over the span it names; unlike `Plot::set_x_note()` (the `t0` caption), which
|
||||
is chrome drawn at a fixed corner of the axes regardless of the data, a band pans and zooms with
|
||||
the samples it accuses. The two are the complementary cases: the caption is chrome that data does
|
||||
not move, the band is annotation that data does. Both are alike in the second count: neither
|
||||
reserves layout space and neither can be picked, so a hole cannot move the layout the labels were
|
||||
measured into.
|
||||
|
||||
The band has a **one-device-pixel floor on its width**. A hole of one sample in a window of hundreds
|
||||
of thousands is a hundredth of a column, and a fill rounded to zero would drop exactly the case
|
||||
the band is most needed for — a sub-pixel hole is invisible as a gap in the polyline and would be
|
||||
invisible as a band too without the floor.
|
||||
|
||||
The band carries **three states** and they are a property of the hole, not of the plot: solid
|
||||
(`Style::invalid_fill`) while the hole still lacks packets, faint (`Style::invalid_repaired_fill`)
|
||||
once `Hole::repaired()` is true, absent once the hole's slots age out of the ring and
|
||||
`forget_holes_before()` drops it from the table. A repaired hole is **not** retired early: the band
|
||||
is a record of what the window was missing, and retiring it on repair would erase the report the
|
||||
moment it became explicable — the faint tier is how the display says "this was missing but arrived
|
||||
late". `RowSet` sends one `plot::InvalidSpan` per hole to every row — a gap is in the stream, not
|
||||
in one signal — and only in `Live` and `Capturing`, because a `Held` or `Paused` snapshot carries
|
||||
its own stamps that the live hole table does not address.
|
||||
|
||||
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
||||
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
||||
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
||||
override is exactly the state in which two routes to a colour disagree. So `install()` builds
|
||||
the trace through this call and all three chip sites — the signal panel, the measures pane and
|
||||
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
|
||||
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
|
||||
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
|
||||
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
||||
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
||||
chip's own markup, since asking the resolver twice would assert nothing.
|
||||
|
||||
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
|
||||
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
|
||||
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
|
||||
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
|
||||
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
|
||||
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
|
||||
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
|
||||
leaving them off.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Invariants: readouts, cursors and the value formatter
|
||||
|
||||
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A readout quotes a stored sample, never a value between two of them.** The hover
|
||||
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
|
||||
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
|
||||
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
|
||||
average* over `time_stride` samples, so a number between two stored samples is a second
|
||||
estimate laid over the first that nothing on screen or in an export holds; and past the sample
|
||||
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
|
||||
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
|
||||
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
|
||||
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
|
||||
regression, not a feature.
|
||||
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
|
||||
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
|
||||
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
|
||||
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
|
||||
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
|
||||
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
|
||||
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
|
||||
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
|
||||
width of the panel at a few samples across, which is the only zoom at which anyone is asking
|
||||
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
|
||||
The hover mark is also deliberately *larger* than a trace's own sample dots
|
||||
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
|
||||
and at their size reads as one more sample. The guards are the library's
|
||||
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
|
||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||
y=..` agreeing with the pane's first value.
|
||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
|
||||
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
|
||||
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
|
||||
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
|
||||
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
|
||||
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
|
||||
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
|
||||
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
|
||||
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||
that range falls back to `%.3g`.
|
||||
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
||||
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
||||
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
||||
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
|
||||
shared by every row -- a row that has not been re-written yet hands the *previous* shared
|
||||
time straight back. That is the "drag it and it springs back" defect, and the fix is
|
||||
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
|
||||
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
|
||||
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
|
||||
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
|
||||
moving the cursor.
|
||||
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
|
||||
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
|
||||
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
|
||||
how a row added while the cursors were already on came up with its instruments parked
|
||||
while every other row showed them. The same goes for the vertical pair's `x`: it is one
|
||||
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
|
||||
genuinely per row (the rulers' values, the traces, the colours).
|
||||
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
|
||||
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
|
||||
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
|
||||
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
|
||||
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
|
||||
marker says where the scope put the edge and a measurement mark says what a measurement measured;
|
||||
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
|
||||
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Invariants: rendering — index mapping, gather, LOD and grading
|
||||
|
||||
How the renderer maps a visible range onto samples, what `gather()` emits and relative to what, and why a dense trace is drawn one pixel column at a time.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The renderer maps the visible range onto indices through the series' own x, never the
|
||||
`dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the
|
||||
bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is
|
||||
a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so
|
||||
the window it computes lands inside the visible range and the two end segments — the ones that
|
||||
draw into the left and right margins — are never emitted. That is the "empty margin when
|
||||
zoomed in" defect. The library test is
|
||||
`a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the
|
||||
window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now
|
||||
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
|
||||
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
|
||||
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
|
||||
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
|
||||
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
|
||||
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
|
||||
capture. The renderer picks its visible index window from this call, so the drift arrived as a
|
||||
**constant offset in seconds** between the axis and the gathered geometry — and a constant
|
||||
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
|
||||
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
|
||||
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
|
||||
into a held event clips it more and more from the left while the crosshair still reads the
|
||||
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
|
||||
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
|
||||
this at all** — three probe runs against the ideal mock were clean — so the guard needs
|
||||
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
|
||||
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
|
||||
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
|
||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
||||
relative to an origin because absolute epoch seconds do not survive `f32`.
|
||||
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
|
||||
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
|
||||
rules live here and both were learned from a defect.
|
||||
(1) **It must be relative at all.** An event's stamps are small (edge relative,
|
||||
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
|
||||
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
|
||||
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
|
||||
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
|
||||
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
|
||||
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
|
||||
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
|
||||
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
|
||||
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
|
||||
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
|
||||
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
|
||||
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
|
||||
site that was not thought about is a compile error rather than silent drift. A caller that has
|
||||
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
|
||||
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
|
||||
be asked to stage. The two guards are upstream's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
|
||||
`trace ink:` line's `drawn-vs-quoted` figure.
|
||||
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
|
||||
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
|
||||
being the renderer's and its pixel readings mean nothing.
|
||||
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
||||
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
||||
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
||||
empty one is re-gathered sample by sample on every frame it is bound. That was the
|
||||
"large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at
|
||||
1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The
|
||||
pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it
|
||||
is built once in `publish_event()` inside the `O(n)` pass that already computes the y
|
||||
bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per
|
||||
channel. `frame path:`'s `segments` is the guard.
|
||||
**"Every" includes the pause**, and that is the half this invariant was written without and
|
||||
which then went wrong for real: `publish_pause_view()` filled `stamps`, `values` and `y` and
|
||||
left `pyramids` empty, so a *paused* view was re-gathered raw on every frame. Both halves of
|
||||
the cost are readable, and the second is the one a performance line cannot show.
|
||||
`frame path:` read `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run
|
||||
live, with the draw mean 12.34 → 18.18 ms — and the **trace changed appearance**, which is what
|
||||
gets reported. `stage_trace_density()` grades a column against *its own peak* from a `1/height`
|
||||
deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far spikier
|
||||
distribution, and a far larger peak to normalise against, than one fed the one or two bucket
|
||||
envelopes the pyramid gives: the paused trace read as a hard bright core with its dwell tail
|
||||
dimmed out. **A pause is not a cheaper case than a capture — it freezes the same rings**, so
|
||||
anything `publish_event()` does to a snapshot, `publish_pause_view()` owes too. Building it is
|
||||
not free (see `publish cost:` below): it took the pause publish from 6.15 ms to 22.31 ms, a
|
||||
one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at
|
||||
~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without
|
||||
losing packets (`gaps=0` with and without a pause, the paused run at an 86 ms publish).
|
||||
**The check that the look is restored is `frame path:` across the pause, not the cost line**:
|
||||
a paused run must read the same path as a live one — measured `raw=1 lod=555 columns=555
|
||||
graded=555` against `raw=0 lod=373 columns=373 graded=373`, same 24711-segment worst frame,
|
||||
segment mean within 5 % — where the defect read `raw=21 lod=118`. A `raw` in the tens over a
|
||||
pause is this returning, and it is the appearance as much as the cost.
|
||||
- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.**
|
||||
Point decimation hides exactly the spike the capture was triggered on; a scope has to
|
||||
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
|
||||
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
|
||||
0 and a window index *is* the absolute one.
|
||||
- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps
|
||||
the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled*
|
||||
under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or
|
||||
noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a
|
||||
bucket at one to two pixel columns, `Envelope` emits two points per bucket so each
|
||||
contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per
|
||||
column per trace), and every one lands at a **fractional** x because a trace is deliberately
|
||||
not snapped. Under src-alpha compositing a column's brightness is then a non-linear function
|
||||
of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket
|
||||
pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s
|
||||
`Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column
|
||||
instead, so the trace has **one** phase and there is nothing left to beat. Four things to
|
||||
know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the
|
||||
gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw
|
||||
path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction
|
||||
off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole
|
||||
stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one
|
||||
carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is*
|
||||
a pixel column.
|
||||
**It needs a thin ribbon to be visible at all** — a window whose buckets span several periods
|
||||
paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window
|
||||
view says nothing either way. The guards are upstream's
|
||||
`a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels**
|
||||
in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates
|
||||
nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next
|
||||
column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this
|
||||
path: it recomputes the **gathered** points, which the reduction is downstream of, so its
|
||||
`drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone.
|
||||
- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The
|
||||
column above is honest about *where* the signal went and silent about *how long it was there*
|
||||
— a column's min and max are identical whether its samples fill the span or pile at its two
|
||||
ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead
|
||||
(bright where the signal sits, faint where it only passes through, the digital-phosphor look),
|
||||
and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by
|
||||
an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in
|
||||
the window build: `PlotArea::renderer()` is null until the area is realized, so there is no
|
||||
earlier place to state a style. Three things to know here, the rest upstream in
|
||||
`docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the
|
||||
per-column-peak normalisation, the quantised alpha steps):
|
||||
* **The finer gather level is floored at 1 whenever the flat path would have used the
|
||||
pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and
|
||||
`MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one
|
||||
bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible)
|
||||
gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat
|
||||
path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look*
|
||||
(fewer sub-columns than asked for, down to what the coarsest buckets give — still two or
|
||||
three per column at 64 samples a bucket) and never the path. It is also why
|
||||
`intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit.
|
||||
* **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B
|
||||
that makes either line readable against the ungraded path (see
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
* **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of
|
||||
geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the
|
||||
gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the
|
||||
segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra
|
||||
geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the
|
||||
library's own guard, 8303 segments over a 572 px axis).
|
||||
The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways
|
||||
and profiles the band's outer fifths against its middle: flat reads **191/191**, graded
|
||||
**191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and
|
||||
`intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one
|
||||
sub-bucket deep everywhere, is never dimmed out of existence.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Invariants: the session file, the export and the source chooser
|
||||
|
||||
What a session persists and in what order, when a save is refused, and when an export is collected.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
||||
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
||||
graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
|
||||
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
||||
when absent. Signal identity
|
||||
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
||||
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
||||
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
||||
that name is only a signal once its graph is in. Two format rules, both learned the hard way:
|
||||
write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as
|
||||
the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so
|
||||
writing one and reading the other silently renames operations. And a top-level key written after
|
||||
a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]`
|
||||
was parsed into the last calibration record and the enable state was lost without a word.
|
||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||
window the user never saw. The writer is picked by the chosen path's extension.
|
||||
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||||
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||||
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||||
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
||||
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
|
||||
`--server`**, which is what makes the feature invisible to the regression net — a probe added
|
||||
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
|
||||
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
||||
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
||||
removed `gtk_dialog_run()`).
|
||||
- **A session file's name cannot say how its source was reached, so the file says it.**
|
||||
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
||||
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
||||
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
|
||||
that table's key in TOML, which is exactly how `enabled` was parsed into the last
|
||||
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
|
||||
loads as unicast on the default route. The chooser's list filters to sessions that **name at
|
||||
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
||||
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||||
`sanitize()` turns anything unsafe in a host into one.
|
||||
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
|
||||
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
|
||||
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
|
||||
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/<host>_<port>/` — holding one `<name>.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 `<host>_<port>.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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Invariants: the time axis and the window control
|
||||
|
||||
What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
|
||||
the same reason as the status label: it sits in the header bar above the plot, so a label
|
||||
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
|
||||
on a mere change of window. That is also why the window is typed into a popover rather
|
||||
than shown in the bar.
|
||||
- **No plot interaction changes the time scale; only the window control does.** The *time scale*
|
||||
is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
|
||||
trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
|
||||
(which may re-plan the partition, move the decimation and **drop the history**), the window
|
||||
control's readout and the session file. A **zoom or a pan** does not touch it by construction —
|
||||
the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
|
||||
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
|
||||
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
|
||||
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
|
||||
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
|
||||
an event span left on the *old* window until the next decade step or unit change applied the
|
||||
fitted value for real (one action, two changes of time scale).
|
||||
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
|
||||
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
|
||||
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
|
||||
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
|
||||
must not come here for two reasons: it is not entitled to the scale at all, and
|
||||
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
|
||||
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
|
||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||
capture up to the requested window.
|
||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||||
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||||
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||||
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||||
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||||
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||||
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||||
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||||
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||||
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||||
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||||
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||||
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||||
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||||
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||||
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||||
than which form a given window gets.
|
||||
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||||
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||||
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||||
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
||||
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
||||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||
those labels were measured into. The guards are the library's
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||||
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||||
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||||
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
|
||||
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
|
||||
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
||||
than the requested window. It must write the per-row **member** axes as well as the link group:
|
||||
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
|
||||
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||
the two cannot drift apart.
|
||||
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
|
||||
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
|
||||
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
|
||||
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
|
||||
nothing newer was said. Without the marker, clicking a source row after applying the preference
|
||||
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
|
||||
@@ -0,0 +1,290 @@
|
||||
# Invariants: the trigger, the capture and a pinned view
|
||||
|
||||
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
||||
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
||||
from "the ring has only just started filling", so `window_s_` — what the window control
|
||||
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
|
||||
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
|
||||
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
|
||||
other state instead — while the ring is filling the two are equal, but once it holds
|
||||
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
|
||||
view into data the window was never set to show. `window_s_` is right in both: it never
|
||||
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
|
||||
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
|
||||
reports it as `zoom ... span=.. window=.. limited=1`.
|
||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||
left-hand side drags the live edge off the right-hand edge.
|
||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||
- **The trigger's event window is separate from the display window**, and
|
||||
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
||||
- **The event window is counted in stream seconds end to end; nothing converts it to
|
||||
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
|
||||
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
|
||||
search over the time ring. There used to be a measured samples-per-second estimate
|
||||
in `Acquisition` doing that conversion, and it is precisely why the post window came
|
||||
out empty: any stream whose time signal advances in large steps drove it to its
|
||||
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
||||
itself. **Do not reintroduce a rate estimate on this path.**
|
||||
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
|
||||
drops an open capture when the trigger moves to a different channel, and it is re-called from
|
||||
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
|
||||
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
|
||||
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
|
||||
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
|
||||
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
|
||||
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
|
||||
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
|
||||
and no user action beyond enabling a channel. The name changes when the source genuinely
|
||||
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
|
||||
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
|
||||
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
||||
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
||||
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||
clamps the position into it (and states `Off`, since a following group would re-derive from a
|
||||
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
|
||||
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
|
||||
in single trigger mode: an acquired event could not be examined at all.
|
||||
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
|
||||
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
|
||||
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
|
||||
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
|
||||
what the clamp last wrote: a member that matches it is our own answer coming back, one that
|
||||
differs is a gesture to honour -- which is also what keeps a *fresh* pin
|
||||
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
|
||||
from being reverted to the member's stale window. Without it nothing bounds a drag or
|
||||
a zoom in those states: the library clamps a *following* group's offset only, and the live
|
||||
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
|
||||
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
||||
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
||||
picture is equally immovable; resuming or leaving trigger mode clears it.
|
||||
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
|
||||
file view states its pin from the ring's own stamps every frame, and it used to do that with
|
||||
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
|
||||
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
|
||||
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
|
||||
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
|
||||
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
|
||||
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
|
||||
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
|
||||
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
|
||||
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
|
||||
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
|
||||
`1`.
|
||||
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
||||
Two things had to agree for this, and both were wrong in the same direction:
|
||||
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
||||
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
|
||||
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
|
||||
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
|
||||
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
|
||||
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
|
||||
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
|
||||
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
|
||||
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
|
||||
is empty on purpose, and that is the capture filling in view, not a margin.
|
||||
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
|
||||
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
|
||||
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
|
||||
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
|
||||
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
|
||||
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
|
||||
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
|
||||
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
|
||||
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
|
||||
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
|
||||
pause now agrees.)
|
||||
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
|
||||
capture may legitimately be shorter than asked for — the ring need not reach back a
|
||||
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
|
||||
for a ±1 s request is correct there rather than a defect.
|
||||
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
|
||||
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
|
||||
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
|
||||
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
|
||||
step with a new sequence number it is not bound to. (The other case is benign and was
|
||||
always handled: a publication and the *next* edge in one frame, where the capture has
|
||||
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
|
||||
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
|
||||
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
|
||||
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
|
||||
frame is bound to the snapshot. Visible only at high rates, where the publish is long
|
||||
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
|
||||
which means **running the disarm probe at 10 kS/s alone does not check this.**
|
||||
- **A capture in progress is the one place the axis is pinned from the *request*.** There
|
||||
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
|
||||
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
|
||||
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
|
||||
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
|
||||
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
|
||||
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
|
||||
A capture is live data on a pinned axis — the exact combination the clamp was written
|
||||
for and against: it would follow the live edge and drag the frozen window off the event.
|
||||
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
|
||||
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
|
||||
and index-aligned by construction instead of by two truncations that can disagree —
|
||||
`EventSeries::gather()` indexes both at the same `i`.
|
||||
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
|
||||
export.** Both need the same absolute index range over the time ring and every value
|
||||
ring, or an export and a capture of the same window would hold different samples. Call
|
||||
it under `mutex()`.
|
||||
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
||||
so the engine would not latch; but the real reason the samples are withheld is the other one:
|
||||
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
||||
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
|
||||
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
|
||||
the arming, the hold-off and the post window and holds no condition.** The split is what lets
|
||||
the buffer search ask the trigger's exact question without owning a trigger, and it is only
|
||||
true as long as neither half leaks into the other: a detector that knew about the hold-off
|
||||
would answer a search differently from the live trigger, which is the one thing the
|
||||
`agree=1` cross-check in `[search-probe]` exists to catch.
|
||||
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
|
||||
A pulse width, a window excursion and a dropout timeout are all *stateful over several
|
||||
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
|
||||
then measures from whatever sample the engine happened to resume on. The hold-off is applied
|
||||
to the detector's *answer*, not to its input — `process()` feeds first and tests
|
||||
`now_s < holdoff_until_s_` afterwards.
|
||||
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
|
||||
the closing edge but could in principle be timed at the opening one, and a dropout is detected
|
||||
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
|
||||
`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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,513 @@
|
||||
# Verification
|
||||
|
||||
How to run the headless self-check, the two baselines every run is read against, and the environment variables that schedule each probe.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
There is no unit test suite. The only automated check is the app's own headless
|
||||
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
|
||||
a live UDPStreamer source:
|
||||
|
||||
```sh
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
||||
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
|
||||
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
|
||||
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
|
||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
||||
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
|
||||
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
|
||||
all. A fifth warning, or any critical, is a finding.
|
||||
|
||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
||||
|
||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||
`panel controls:` line was already written that way for the same reason, and that comment is the
|
||||
precedent, not an exception.
|
||||
|
||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||
frames, trace bindings, panel sizes, status line in both states).
|
||||
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
|
||||
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
||||
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
||||
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
||||
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
|
||||
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
|
||||
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
|
||||
enables the sine — which sorts before the square and so moves the trigger channel's index under
|
||||
the engine. Its three lines are in [probe-readings.md](probe-readings.md); **`t0_held=1` is the
|
||||
assertion**. Run it with `--holdoff 10`, which makes the stolen-t0 reading unmistakable: a second
|
||||
latch barely a second after the first cannot be a hold-off that applied.
|
||||
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
||||
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
||||
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
||||
SIGSEGV *is* the assertion.
|
||||
- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a
|
||||
number, change the unit under it, step a decade either way.
|
||||
- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the
|
||||
first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms
|
||||
apart on purpose — the buttons are placed from the rects the *last frame* was laid out
|
||||
with, so a synchronous sequence would read every position one action stale. It clicks by
|
||||
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
|
||||
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
|
||||
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
|
||||
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
|
||||
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
|
||||
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
|
||||
measurements that all read `--` *and* makes a performance run look free (each bails before
|
||||
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
||||
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
||||
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
|
||||
mean of a sine, which is the check that the threshold types are not merely plausible.
|
||||
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
||||
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
|
||||
data is there -- that was the rulers blinking off at a mode change.
|
||||
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
|
||||
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
|
||||
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
|
||||
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
|
||||
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
|
||||
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
|
||||
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
|
||||
measurement-cost invariant in
|
||||
[invariants-measurements.md](invariants-measurements.md)). A `raw` near `covered` means the walks went back to reading
|
||||
the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is
|
||||
*legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy
|
||||
enough to cross a threshold everywhere, so read it on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
||||
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
|
||||
that is the interferer's own cost rather than a regression -- the figure to compare a change
|
||||
against is the **clean** one.
|
||||
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
||||
with nothing bound and the axis at its default. The race-free reading is an earlier
|
||||
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
|
||||
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
|
||||
|
||||
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
|
||||
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
|
||||
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
|
||||
that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a
|
||||
gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and
|
||||
reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during
|
||||
a capture whose check lands after the capture has ended is re-framed by the mode change, not
|
||||
by the pin, and reads `held=1` with the hold removed (that is how the first version of this
|
||||
probe fooled me). The pause probe makes a stable pin, so run them together:
|
||||
`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside
|
||||
the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the
|
||||
asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and
|
||||
reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** --
|
||||
that is the single-trigger-mode case, where the wheel has to keep working, and where reading
|
||||
the wrong one of the two made a working clamp look broken. All three verdicts are read from the
|
||||
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
|
||||
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
|
||||
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
|
||||
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
|
||||
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
||||
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
||||
|
||||
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
|
||||
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
|
||||
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
|
||||
into), then zooms in eight doublings
|
||||
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
|
||||
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
|
||||
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
|
||||
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
|
||||
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
|
||||
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
|
||||
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
|
||||
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
|
||||
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
|
||||
invariant in [invariants-rendering.md](invariants-rendering.md)). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
|
||||
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
|
||||
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
|
||||
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
|
||||
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
|
||||
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
|
||||
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
|
||||
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
|
||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||
glance=[..]` on stderr):
|
||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||
stops — it fills the live ring exactly as it does with the trigger off), **`event_size`
|
||||
must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not
|
||||
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
||||
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
||||
the producer, which is why the counts grew by nothing and the resume left a hole.
|
||||
`frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze
|
||||
instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a
|
||||
`frozen_x` in the thousands is the rebase gone.
|
||||
**`glance=` is the assertion for the paused status field**, and this is the only line that can
|
||||
make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and
|
||||
triggered states. While paused it must read `paused · <rate> · <window> · for <elapsed>`, with
|
||||
the elapsed figure **growing between the two paused lines** and **absent on the resume line**.
|
||||
It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never
|
||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||
instead of growing digits (see the layout rule).
|
||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||
nothing. Its four lines are in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||
only the file chooser.
|
||||
|
||||
- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched
|
||||
`t0`, the measured pulse or band width, and sample values for the window condition. Schedules its
|
||||
own arm and report stages. `UDPSCOPE_PROBE_CONDITION_SHAPE` picks the leg: `0` pulse width `<`
|
||||
(the glitch, the default, needs `--glitch-at`/`--glitch-width`), `1` pulse width `>` (uses a
|
||||
1.0 s window and 50 % pre so the opening edge of a 0.2 s pulse is captured), `2` window
|
||||
(uses `sine` with a 0.2 s window and 10 % pre), `3` dropout (needs `--quiet-at`; uses `sine`
|
||||
with a 1.0 s window and 50 % pre so the last crossing before the outage is captured).
|
||||
- `UDPSCOPE_PROBE_STATS_MS` — reports every measurement's accumulator (`n`, mean, sd, lo, hi),
|
||||
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
|
||||
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
|
||||
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
|
||||
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
|
||||
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
|
||||
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
|
||||
|
||||
The following mock waveform features are what the qualified-condition probes are checked against,
|
||||
as distinct from the fault injection below, which exercises ingest:
|
||||
|
||||
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of
|
||||
every square period from stream second `S` onwards — a runt pulse of a known width, which is
|
||||
what a `<` pulse-width trigger and a glitch search are checked against.
|
||||
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops
|
||||
crossing zero: the dropout trigger's input.
|
||||
|
||||
- `UDPSCOPE_PROBE_RESEQ_MS` — one mid-run reading of the resequencing path. Five cases,
|
||||
**run each as its own process** — five back to back starve the mock past its 2 s silence
|
||||
timeout and every case then reads a reconnect instead of what it was testing. Common
|
||||
conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with
|
||||
no extra options, under `xvfb-run` with
|
||||
`UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1
|
||||
UDPSCOPE_PROBE_RESEQ_MS=5000`. Delete `~/.config/udpscope/127.0.0.1_8000.toml` first: a
|
||||
session left by an earlier run restores its math channel, and five channels instead of four
|
||||
change `channels=`, `span=` and every count derived from them.
|
||||
|
||||
| case | mock extra flags | key reading |
|
||||
| --- | --- | --- |
|
||||
| **base** | *(none)* | every counter 0; proves the machinery costs nothing |
|
||||
| **reorder** | `--reorder-every 10` | `reseq=51` (mid-run) / `102` (dump); `holes=0`; resequencing inside the window leaves no reservation |
|
||||
| **late** | `--reorder-every 10 --reorder-delay-ms 50` | `bands_repaired` one short of `bands` with `nan_slots=100`; the band still solid is the one hole whose late packet is in flight, and it is the *only* unrepaired one |
|
||||
| **mixed** | `--drop-every 20 --reorder-every 10 --reorder-delay-ms 50` | `bands=43 bands_repaired=20 nan_slots=2200`; all three band states at once |
|
||||
| **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 <capture> 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 '<name>': 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] <view> N '<label>' h=18 label_x=26 accel_right=R/W ok=1` per row and one
|
||||
`rows=/bad=/labels=` line per view, then `[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` and `[rowmenu] remove-clicked 'sine' trace_freed=1`.
|
||||
**Every `bad` must be 0** (the same compact row style as the app's own menu: one label column, a
|
||||
shortcut flush right, 18 px rows — the **Back** row's icon indents its label, which every menu
|
||||
does, so its `label_x` is 52 against the others' 26) and the three booleans must all read 1: the
|
||||
remove view *replaces* the main one (`remove_row_gone`, `remove_row_in_main`), and it can be
|
||||
entered again after leaving it (`can_open_again`) — that last one is the case a
|
||||
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
||||
measured a step after the click that swapped it in, because a popover allocates its new child on
|
||||
the next layout pass.
|
||||
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
||||
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
||||
dialog's own reader would then apply — which is exactly what the calibration offset did. It then
|
||||
calibrates signal 0 through `set_calibration()` and reopens the dialog:
|
||||
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
|
||||
calibration**, and the two halves must match.
|
||||
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
|
||||
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
||||
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
||||
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
|
||||
configuration carried, into the `Config` the model is built from. A legacy file
|
||||
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
|
||||
holding `default`, and the old file renamed `.imported`.
|
||||
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads
|
||||
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
|
||||
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
|
||||
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
|
||||
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
|
||||
the newest event through the pane's own row:
|
||||
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
|
||||
rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then
|
||||
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
|
||||
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
|
||||
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
|
||||
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
|
||||
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
|
||||
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
|
||||
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
|
||||
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
|
||||
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
|
||||
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
|
||||
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
|
||||
door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts
|
||||
through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the
|
||||
buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0
|
||||
b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a
|
||||
gap: the first event's channel is finite only in the first half and the second's only in the second**
|
||||
-- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000
|
||||
recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
|
||||
running pushes its own four signals into the group's eight channels), `[events] compare plots:
|
||||
area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is
|
||||
on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y
|
||||
range nobody would arrive at by accident on the *first* plot and reads the second a few frames later:
|
||||
`[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1
|
||||
channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout
|
||||
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
|
||||
means). Run it with a throwaway
|
||||
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
|
||||
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
|
||||
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
|
||||
exit dump. The load must print
|
||||
`[udpscope] waveform loaded: … signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000]`
|
||||
— **the same row count, column count, span and time range the export probe reported**
|
||||
(`rows=20001 cols=4 time=[-2.000000..0.000000]`) — and the dump must show `channels=4`,
|
||||
`frames=0 packets=0` (no acquisition) and, per channel,
|
||||
`20001 samples vs 20001 time slots … y=[-1.0000, 1.0000]`. A file that loads with fewer samples
|
||||
than it was written with, or with `NaN` slots, is the reader mis-slicing its records.
|
||||
- **A loaded file can be plotted.** `UDPSCOPE_PROBE_DROP_MS` drives the drop handler the way a drag
|
||||
from the signal panel does, on a file opened with `--open`: it must read
|
||||
`[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||]` — **`rows` equal to the column count**,
|
||||
one trace per row — and each drop must return `1` and add its trace (`dropped signal 1 -> 1
|
||||
slots=[sine|sine|square|]`). `rows=1 slots0=[sine|square|ramp|noise]` with every drop returning `0`
|
||||
is the defect this exists for: rows sized four signals to one row fill the only row, so the app
|
||||
reads as one that cannot plot anything. A file-mode run must also write **no session** for the
|
||||
default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout.
|
||||
- **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints
|
||||
`[sources] waveforms: N recent <path>(ok|missing) … page=waveforms`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
|
||||
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
|
||||
load line above, with no socket involved.
|
||||
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
|
||||
holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
|
||||
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
|
||||
must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
|
||||
`relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
|
||||
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
|
||||
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
|
||||
ignored whenever the address came from the command line.
|
||||
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
|
||||
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
|
||||
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
|
||||
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
|
||||
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
|
||||
(`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
|
||||
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
|
||||
configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
|
||||
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
|
||||
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
|
||||
`known:` row afterwards, with the old file gone.
|
||||
- `UDPSCOPE_PROBE_RESEQ_MS`'s neighbour for **reconnect**: the mock gains an EMI case,
|
||||
`--blackout-at S --blackout D` — silent from second S for D seconds (D must exceed the
|
||||
client's 2 s silence timeout), resuming from *now* so the link's backlog is what the link
|
||||
ate. One run (`--blackout-at 4 --blackout 3.5`, `UDPSCOPE_EXIT_AFTER_MS=15000`) has to read:
|
||||
every reconnect's `CONFIG:` line followed by `CONFIG unchanged: keeping the buffer and its
|
||||
history` (a reconnect that re-plans wipes history, which is the defect this case exists for),
|
||||
no `buffer rebuilt` line after the first CONFIG, `frames` climbing again after the blackout
|
||||
with `gaps=0`, and a trigger armed *after* recovery (`UDPSCOPE_PROBE_KEYS_MS` past the
|
||||
blackout) latching — the dump's `CAPTURING`/`EVENT HELD` and `publish cost: N events` with
|
||||
N ≥ 1 are that proof.
|
||||
already covers 838 s, so no window the control can reach moves `time_stride`,
|
||||
`set_target_span()` returns without rebuilding, and the window probe reports `decim=1`
|
||||
throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's
|
||||
500 ms → 500 s → 120 s → 600 s → 60 s walk crosses the boundary four times.
|
||||
|
||||
Full measured readings (probe line first, then exit-dump):
|
||||
|
||||
```
|
||||
=== base mock: none
|
||||
[reseq-probe] reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0
|
||||
bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0
|
||||
[udpscope] frames=1032 packets=1033 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0
|
||||
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
[udpscope] channel 0 'sine' stored=95700 published=95400
|
||||
|
||||
=== reorder mock: --reorder-every 10
|
||||
[reseq-probe] reseq=51 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=1027 packets=1028 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0
|
||||
stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0
|
||||
[udpscope] channel 0 'sine' stored=95300 published=95100
|
||||
|
||||
=== late mock: --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=157 late=46 stale=0 overflows=0 holes=40 hole_slots=4000 backfilled=15600
|
||||
open_holes=40 bands=40 bands_repaired=39 nan_slots=100 scanned=44000
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=1027 packets=1028 gaps=98 channels=4 span=838.861s stride=1 reseq=296
|
||||
late=98 stale=0 overflows=0 holes=91 hole_slots=9100 backfilled=36400
|
||||
[udpscope] channel 0 'sine' stored=95300 published=95000
|
||||
|
||||
=== mixed mock: --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=159 late=25 stale=0 overflows=0 holes=43 hole_slots=4300 backfilled=8400
|
||||
open_holes=43 bands=43 bands_repaired=20 nan_slots=2200 scanned=44100
|
||||
trigger_in_hole=0
|
||||
[udpscope] frames=978 packets=981 gaps=101 channels=4 span=838.861s stride=1 reseq=309 late=50
|
||||
stale=0 overflows=0 holes=94 hole_slots=9400 backfilled=18400
|
||||
[udpscope] channel 0 'sine' stored=95400 published=95400
|
||||
|
||||
=== re-plan mock: --rate 100000 --drop-every 20 --reorder-every 10 --reorder-delay-ms 50
|
||||
[reseq-probe] reseq=4374 late=72 stale=0 overflows=36 holes=315 hole_slots=31136
|
||||
backfilled=26800 open_holes=64 bands=64 bands_repaired=16 nan_slots=7800
|
||||
scanned=156600 trigger_in_hole=0
|
||||
[udpscope] frames=8949 packets=8953 gaps=614 channels=4 span=83.886s stride=1 reseq=8028
|
||||
late=144 stale=0 overflows=36 holes=603 hole_slots=59936 backfilled=55600
|
||||
[udpscope] channel 0 'sine' stored=588600 published=584600
|
||||
```
|
||||
|
||||
The `late` case's `bands_repaired` is one short of `bands` with `nan_slots=100` — exactly one
|
||||
packet's worth of slots at stride 1 — because the probe fires 50 ms-delayed releases into a
|
||||
window that always has one in flight. An earlier recording of this case read
|
||||
`bands_repaired=42 nan_slots=0`; both are healthy, and which one a run gets is where the
|
||||
probe's instant falls relative to the newest release. A `bands_repaired` *above* the count of
|
||||
holes whose slots hold values is the defect, in either direction.
|
||||
|
||||
`holes` far above `open_holes` in the re-plan case is the two bounds working: a re-plan clears
|
||||
the table (the old entries address a buffer that is gone) and `kMaxHoles` caps what survives
|
||||
at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because
|
||||
nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md).
|
||||
|
||||
The mock also carries three **fault injections** that no probe schedules, because what they exercise
|
||||
is the ingest rather than the UI. The first: `--time-glitch-at <stream seconds> --time-glitch-by <seconds>` makes
|
||||
**one** packet's time signal jump ahead and then carries on from where the true timeline was, which
|
||||
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
|
||||
|
||||
```sh
|
||||
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=400 UDPSCOPE_ARM_TRIGGER_MS=700 UDPSCOPE_EXIT_AFTER_MS=22000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000 --holdoff 10
|
||||
# mock: --rate 10000 --batch 100 --freq 5 --time-glitch-at 6.0 --time-glitch-by 10.0
|
||||
```
|
||||
|
||||
Four readings, all off the once-a-second `[udpscope] trig engine:` line:
|
||||
|
||||
- exactly one `warning: ... jumped implausibly far ahead of the previous packet (16.01..16.0199
|
||||
against a previous 6.0099); bridging that packet ...` — the glitch was **caught**;
|
||||
- **no** `timing samples by arrival instead` — one bad packet must not change the time source;
|
||||
- `now_t` crosses the glitch continuously (`5.040 → 6.050 → 7.050`), and `history` keeps **growing**
|
||||
(`1.01 … 23.15`) instead of collapsing — the ring was not wiped;
|
||||
- the latches stay a hold-off apart: `edge at 1.060 / latched at 2.060 / holdoff_until=12.060`, then
|
||||
`edge at 12.260 / latched at 13.260 / holdoff_until=23.260` — **11.2 s** for a 10 s hold-off plus
|
||||
a 0.9 s post window.
|
||||
|
||||
Before the fix the same run read a fallback warning, `history` collapsing 4.03 → 0.95, `now_t`
|
||||
jumping 5.040 → 16.040, and two latches **1.2 s** apart. Note the window here comes from the saved
|
||||
session for that port; the figures above are the default 2 s window (50 % pre), so move
|
||||
`~/.config/udpscope/<host>_<port>.toml` aside to reproduce them.
|
||||
|
||||
The other two are **packet loss** (`--drop-every N`, `--drop-burst K`) and **wire inversion**
|
||||
(`--reorder-every N`). Loss withholds a datagram the counter and the stream clock have already
|
||||
advanced over; the client counts the forward jump as `counter_gaps`. An inversion withholds a
|
||||
packet until the one behind it is sent, so the receiver sees a later counter first; a packet that
|
||||
arrives within the 20 ms reorder window is resequenced with no hole, and one that arrives past it
|
||||
leaves a committed hole that it then backfills (`UDPSCOPE_PROBE_RESEQ_MS` exercises both paths).
|
||||
There is **no interpolation** anywhere below the ring.
|
||||
|
||||
All of the readings below are off `UDPSCOPE_EXIT_DUMP`'s `[udpscope] frames= packets= gaps=` line,
|
||||
over an 8 s run against `--rate 10000 --batch 100 --freq 5` (100 packets a second) except where the
|
||||
burst needs the faster stream. **`gaps` is the exact reading** and repeats to the packet; `frames`
|
||||
and `packets` move by one or two with the run's own timing. Run each of these on its own — four of
|
||||
them back to back in one script starved the mock past the 2 s silence timeout and every case,
|
||||
including the clean one, then read a reconnect instead of what it was testing:
|
||||
|
||||
- **clean**, as the reference: `frames=1029 packets=1030 gaps=0`, no warning.
|
||||
- `--drop-every 10`: `frames=925 packets=926 gaps=102`. Every tenth packet never reaches the wire,
|
||||
so `packets` falls with `frames` and `gaps` is the count of them. No warning: a one-packet hole is
|
||||
a one-packet jump in the stamps, far inside the glitch guard.
|
||||
- `--reorder-every 10`: `frames=1028 packets=1029 gaps=0`. The inverted packet lands inside the
|
||||
20 ms reorder window and is resequenced: every datagram arrives and `gaps` is 0. The
|
||||
pre-resequencing signature was `frames=925 packets=1028 gaps=102` — an inversion cost a counter
|
||||
gap and a discarded packet, which is exactly what resequencing removes. Adding
|
||||
`--reorder-delay-ms 50` pushes the arrival *past* the window and is the case the
|
||||
`UDPSCOPE_PROBE_RESEQ_MS` readings above cover; it is not this row.
|
||||
- `--drop-every 1000 --drop-burst 200` at `--rate 100000 --batch 100` (1000 packets a second):
|
||||
`frames=7000 packets=7001 gaps=1600`, eight bursts of 200, and still no warning.
|
||||
- `--drop-every 1000 --drop-burst 300`, same stream: `frames=6203 packets=6204 gaps=2400` **and the
|
||||
guard fires** — `warning: ... jumped implausibly far ahead of the previous packet (1.299..1.29999
|
||||
against a previous 0.99899)`, then `timing samples by arrival instead`. 300 packet spans is past
|
||||
`kGlitchPackets` (256), so real loss is read as a clock glitch; and since each bridge advances the
|
||||
anchor by one packet span while the true timeline is 300 ahead, the next four packets fail too and
|
||||
`kFailureLimit` throws the stream onto the arrival clock. A burst between 256 packets and the 2 s
|
||||
`silence_timeout_s` is the only window in which this is reachable, and it is a finding, not a
|
||||
baseline.
|
||||
|
||||
Keep a burst under 2 s of wire time whatever the rate: past that the client reconnects, the mock
|
||||
resets its counter and clock on the new CONNECT, and the run reads `gaps=0` with a *backwards* stamp
|
||||
warning (`does not advance across packets ... 0..0.0099 against a previous 0.3899`) — the reconnect
|
||||
path, not the loss path.
|
||||
|
||||
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
||||
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
||||
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
||||
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
|
||||
hold ports.
|
||||
|
||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
# UI files and current GTK practice
|
||||
|
||||
Where the application's interface should be a `.ui` resource instead of widget construction, what is
|
||||
already there, and the order to finish it in. Written 2026-10-11.
|
||||
|
||||
## Why
|
||||
|
||||
Three reasons, in the order they bite.
|
||||
|
||||
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
|
||||
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
|
||||
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
|
||||
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
|
||||
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
|
||||
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
|
||||
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
|
||||
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
|
||||
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
|
||||
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
|
||||
move.
|
||||
|
||||
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
|
||||
|
||||
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
|
||||
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
|
||||
file at build time.
|
||||
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
|
||||
every window is what catches that; there is no build-time substitute.
|
||||
- A `GIcon` property takes a nested object, not a name.
|
||||
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
|
||||
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
|
||||
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
|
||||
- A deprecated *widget* is acceptable when no deprecated *function* is called.
|
||||
|
||||
## Acceptance for every slice
|
||||
|
||||
The same as any other change here: the build stays at **four warnings**, a run stays at **no
|
||||
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
|
||||
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
|
||||
`ui=` against `tables=` is the model.
|
||||
|
||||
## Already done
|
||||
|
||||
- `src/ui/main-window.ui` -- the main window's **skeleton**: the window, its two panes, the plot column
|
||||
with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream
|
||||
(the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by
|
||||
code and put into the slots by id.
|
||||
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
|
||||
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
|
||||
- `src/ui/help.ui` -- 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.
|
||||
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||
file back after every change.
|
||||
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
|
||||
- The `src/ui/` GResource and its build block.
|
||||
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
|
||||
and asserts that no row is left without a visual.
|
||||
|
||||
## Remaining, smallest first
|
||||
|
||||
| where | what | why it is the right next step |
|
||||
|---|---|---|
|
||||
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
|
||||
| `build_measures_panel` (`src/ui.cpp`, ~82 lines) | the measurements panel: `.ui` + `tools/check_ui.py` after | small, self-contained, and its own probe |
|
||||
| `src/ui/ui_trigger.cpp` (~22 kB) | the trigger panel: the `.ui` holds the panel and its groups; the signal chips and the condition rows stay in code because they follow CONFIG | the largest user-visible panel |
|
||||
| `src/ui/ui_signals.cpp` (~20 kB) | the signals panel | mostly a list of data-driven rows, so the `.ui` holds the frame |
|
||||
| `src/ui/ui_math.cpp` (~48 kB) | the math panel and the node editor's chrome (the canvas itself is drawn, not declared) | the largest file, and the one where least of it is template |
|
||||
| `src/ui/ui_events.cpp` (~44 kB) | the events pane | the rows are events, the frame is not |
|
||||
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
|
||||
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
|
||||
|
||||
**Every row of the table above is done or accounted for.** The header bar went last and in one change, because
|
||||
`gtk_header_bar_pack_start()` appends after whatever a file has already packed -- staging it by group would
|
||||
have reordered the bar in an intermediate commit. Its frame is `src/ui/header-bar.ui`; the overflow menu's
|
||||
rows, the window control and every handler stayed in code, and the probe's header readings are byte-identical
|
||||
before and after.
|
||||
|
||||
**The layout editor's buttons are not a `.ui` row, and the note above is wrong about them.** Their count *is*
|
||||
the row count (one per row, one per boundary), and `GtkBuilder` instantiates a file's objects once, so a file
|
||||
can hold neither the number nor a prototype for the code to copy. What was real in that row was the
|
||||
duplication: the shared look is now one function (`layout_overlay_button()`), and the layout probe reads
|
||||
byte-identical before and after. See `invariants-gtk-and-chrome.md`.
|
||||
|
||||
**Done since, one row per commit:** the source chooser's window (`0a64e9e`), the measurements panel
|
||||
(`d344361`, `07fcf61`), the trigger panel (`1ccc70c`), the signals panel (`c37fbdd`), the math panel
|
||||
(`90a17eb`) and the math editor's window, identity row and canvas. What is left of that row is the palette
|
||||
and the buttons around the canvas, which stay in code for the reason the canvas's *drawing* does.
|
||||
|
||||
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
|
||||
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
|
||||
`[sidebar-probe]`.
|
||||
|
||||
Each of these is one change on the pattern `src/ui/ui_cursors.cpp` now follows: `ui_load()` the file, resolve
|
||||
the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.
|
||||
|
||||
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
|
||||
@@ -0,0 +1,452 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# The math node editor: wires you can draw and delete, and a canvas you can navigate
|
||||
|
||||
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
|
||||
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
|
||||
panned — and all three are true. The first is a defect with a specific cause, the second has no
|
||||
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
|
||||
rule the data structure has (a node may only read nodes above it in the list) rather than
|
||||
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
|
||||
instead of on the canvas as it is being built.
|
||||
|
||||
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
|
||||
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
|
||||
|
||||
## Scope
|
||||
|
||||
In: the canvas view transform, the port and wire hit tests, the gesture state machine
|
||||
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
|
||||
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
|
||||
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
|
||||
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
|
||||
|
||||
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
|
||||
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
|
||||
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
|
||||
no copy/paste, no undo.
|
||||
|
||||
## Why edges cannot be added today
|
||||
|
||||
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
|
||||
|
||||
```cpp
|
||||
const int port = math_port_at(d, x, y, which);
|
||||
if (port == -2) {
|
||||
const int node = math_node_at(d, x, y);
|
||||
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
|
||||
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
|
||||
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
|
||||
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
|
||||
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
|
||||
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
|
||||
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
|
||||
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
|
||||
exists and the first click of the two-click sequence usually fails.
|
||||
|
||||
It is worth fixing the cause and not only the symptom, because the same re-derivation would
|
||||
break again under the view transform below.
|
||||
|
||||
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
|
||||
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
|
||||
|
||||
## The view transform
|
||||
|
||||
`MathDialog` gains three doubles:
|
||||
|
||||
```cpp
|
||||
double view_scale = 1.0; ///< canvas pixels per graph unit
|
||||
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
|
||||
double view_y = 0.0;
|
||||
```
|
||||
|
||||
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
|
||||
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
|
||||
right default for a window whose size the user did not choose either.
|
||||
|
||||
Two conversions, and **every** hit test goes through the second:
|
||||
|
||||
```cpp
|
||||
// canvas <- graph: applied once to the cairo context, never per item.
|
||||
cairo_translate(cr, d->view_x, d->view_y);
|
||||
cairo_scale(cr, d->view_scale, d->view_scale);
|
||||
|
||||
// graph <- canvas: the inverse, for a pointer position.
|
||||
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
|
||||
gx = (cx - d->view_x) / d->view_scale;
|
||||
gy = (cy - d->view_y) / d->view_scale;
|
||||
}
|
||||
```
|
||||
|
||||
Applying the transform to the context rather than to each item is what keeps the drawing code
|
||||
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
|
||||
sizes scale with the graph, which is what a zoom should do to a diagram.
|
||||
|
||||
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
|
||||
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
|
||||
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
|
||||
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
|
||||
looking for them.
|
||||
|
||||
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
|
||||
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
|
||||
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
|
||||
Fit, reset).
|
||||
|
||||
### Wheel zoom about the pointer
|
||||
|
||||
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
|
||||
graph point under the pointer, and it must not move:
|
||||
|
||||
```cpp
|
||||
double gx = 0.0, gy = 0.0;
|
||||
math_to_graph(d, px, py, gx, gy); // before
|
||||
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
|
||||
d->view_x = px - gx * d->view_scale; // restore the anchor
|
||||
d->view_y = py - gy * d->view_scale;
|
||||
```
|
||||
|
||||
The pointer position comes from the controller's own `GtkEventController` current event rather
|
||||
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
|
||||
it is a second source of truth that can be stale.
|
||||
|
||||
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
|
||||
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
|
||||
|
||||
### Pan, Fit, and reset
|
||||
|
||||
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
|
||||
`drag-begin`, for the same total-offset reason the node drag already documents at
|
||||
`src/ui.cpp:3507`.
|
||||
|
||||
A **Fit** button frames every node:
|
||||
|
||||
```
|
||||
bbox over all math_node_rect() (empty graph -> identity view)
|
||||
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
|
||||
view_* = centre the scaled bbox in the canvas
|
||||
```
|
||||
|
||||
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
|
||||
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
|
||||
|
||||
## The port and wire hit tests
|
||||
|
||||
`math_port_at` is replaced by one that **names the node it found**:
|
||||
|
||||
```cpp
|
||||
/// The port under a *graph* position. Returns the node index, with `which` set to
|
||||
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
|
||||
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
|
||||
```
|
||||
|
||||
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
|
||||
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
|
||||
between the two searches is how a click lands on one node and a drag on another. And the
|
||||
caller never re-derives the owner, which is the defect above made structurally impossible.
|
||||
|
||||
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
|
||||
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
|
||||
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
|
||||
difference between a diagram that can be wired and one that cannot. The asymmetry is written
|
||||
down in a comment at the hit test, because it is what the old defect fed on.
|
||||
|
||||
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
|
||||
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
|
||||
|
||||
```cpp
|
||||
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
|
||||
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
|
||||
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
|
||||
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
|
||||
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
|
||||
```
|
||||
|
||||
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
|
||||
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
|
||||
sample count is a named constant so a future wider tread can raise it.
|
||||
|
||||
**Order of precedence on a press**: port, then wire, then node body, then background. A port
|
||||
sits on a node's edge and a wire ends on a port, so each later test must only see what the
|
||||
earlier ones refused.
|
||||
|
||||
## The gesture state machine
|
||||
|
||||
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
|
||||
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
|
||||
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
|
||||
|
||||
```cpp
|
||||
enum class MathDrag { None, Node, Wire, Pan };
|
||||
MathDrag drag_mode = MathDrag::None;
|
||||
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
|
||||
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
|
||||
int wire_detach_which = 0;
|
||||
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
|
||||
double wire_y = 0.0;
|
||||
double pan_x0 = 0.0;
|
||||
double pan_y0 = 0.0;
|
||||
MathWireRef selected_wire{-1, 0};
|
||||
```
|
||||
|
||||
`drag-begin`, in precedence order:
|
||||
|
||||
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
|
||||
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
|
||||
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
|
||||
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
|
||||
clearing on begin is what makes the preview show the truth: the wire the user is holding.
|
||||
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
|
||||
no separate case.
|
||||
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
|
||||
input). Wiring is always output→input, so this is not a connect gesture.
|
||||
- **A node body** → `Node`, exactly as today.
|
||||
- **Anything else** → `Pan`.
|
||||
|
||||
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
|
||||
|
||||
`drag-end` resolves `Wire`:
|
||||
|
||||
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
|
||||
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
|
||||
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
|
||||
break, and refusing it by name reads better than "the graph has a cycle".
|
||||
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
|
||||
stands.
|
||||
|
||||
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
|
||||
clear-on-begin, or Escape would be a way to delete an edge by accident.
|
||||
|
||||
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
|
||||
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
|
||||
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
|
||||
press, because a drawing area is not focusable by default and a key controller on an unfocused
|
||||
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
|
||||
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
|
||||
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
|
||||
reached by a narrower route.
|
||||
|
||||
## The edge model
|
||||
|
||||
One function owns connection:
|
||||
|
||||
```cpp
|
||||
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
|
||||
/// backwards. Returns false and sets `why` when the wire would make a cycle.
|
||||
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
|
||||
```
|
||||
|
||||
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
|
||||
nothing to validate in removing an edge: a disconnected input is a state the validation line
|
||||
already names.
|
||||
|
||||
### Backwards wires re-sort the list
|
||||
|
||||
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
|
||||
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
|
||||
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
|
||||
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
|
||||
want to feed is frequently the one that happens to sit earlier in the list.
|
||||
|
||||
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
|
||||
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
|
||||
acyclic:
|
||||
|
||||
1. Trial edge set = every node's existing inputs, plus the new one.
|
||||
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
|
||||
nothing leaves the list alone and a sort that must move something moves as little as
|
||||
possible. (A stable order matters: the node positions on the canvas do not change, so a
|
||||
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
|
||||
can see.)
|
||||
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
|
||||
*not* created — this is the one refusal left, and it is the honest one.
|
||||
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
|
||||
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
|
||||
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
|
||||
naming the set.
|
||||
|
||||
The session format is untouched: what is written is still a list in evaluation order. A file
|
||||
written by an older build still loads, because the sort is an editor operation and an
|
||||
already-ordered list sorts to itself.
|
||||
|
||||
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
|
||||
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
|
||||
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
|
||||
|
||||
### The armed-port highlight goes away
|
||||
|
||||
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
|
||||
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
|
||||
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
|
||||
input is a legal target. What replaces the highlight is a hover accent on the port under the
|
||||
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
|
||||
here will connect.
|
||||
|
||||
## The live validation line
|
||||
|
||||
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
|
||||
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
|
||||
the end of a build is the worst time and place to be told; the graph is right there and can say
|
||||
so continuously.
|
||||
|
||||
One extraction:
|
||||
|
||||
```cpp
|
||||
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
|
||||
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
|
||||
/// acquired channel", which the validator cannot make because only the dialog sees that list.
|
||||
std::string math_dialog_problem(const MathDialog* d);
|
||||
```
|
||||
|
||||
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
|
||||
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
|
||||
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
|
||||
that must be dismissed.
|
||||
|
||||
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
|
||||
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
|
||||
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
|
||||
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
|
||||
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
|
||||
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
|
||||
|
||||
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
|
||||
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
|
||||
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
|
||||
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
|
||||
mere change of graph.
|
||||
|
||||
## The op picker on add
|
||||
|
||||
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
|
||||
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
|
||||
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
|
||||
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
|
||||
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
|
||||
and nothing to parse.
|
||||
|
||||
The two behaviours that already work are kept: the new node becomes the output when the graph
|
||||
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
|
||||
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
|
||||
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
|
||||
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
|
||||
|
||||
## Tidy / auto-layout
|
||||
|
||||
A `tidy` button lays the graph out left to right by dependency depth:
|
||||
|
||||
```
|
||||
depth(i) = 0 for a Source or an unwired node
|
||||
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
|
||||
x = 24 + depth * (kNodeW + 48)
|
||||
y = 20 + row_within_depth * (kNodeH + 22)
|
||||
```
|
||||
|
||||
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
|
||||
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
|
||||
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
|
||||
screen is not tidy.
|
||||
|
||||
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
|
||||
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
|
||||
adjacent columns.
|
||||
|
||||
## The `Cancel` button closes rather than destroys
|
||||
|
||||
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
|
||||
|
||||
```cpp
|
||||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||||
```
|
||||
|
||||
This is the defect `CLAUDE.md` documents under the math channel invariants — "a Cancel that
|
||||
*destroyed* the window left `app.math_dialog` pointing at a dead widget: every later
|
||||
`open_math_dialog()` took the 'already open' branch and presented it" — and it is still live.
|
||||
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
|
||||
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
|
||||
which it does not today: it only ever closes programmatically.
|
||||
|
||||
## What `[math-probe]` must assert
|
||||
|
||||
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
|
||||
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
|
||||
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
|
||||
reading, in the style of the existing lines.
|
||||
|
||||
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
|
||||
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
|
||||
the box. Both must name the node. **This is the one line that would have caught the reported
|
||||
bug**, and it is the reason the hit test returns the node rather than a sentinel.
|
||||
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
|
||||
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
|
||||
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
|
||||
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
|
||||
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
|
||||
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
|
||||
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
|
||||
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
|
||||
curve shows.
|
||||
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
|
||||
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
|
||||
is the half that silently deletes an edge if Escape is forgotten.
|
||||
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
|
||||
under the pointer before and after a wheel step, which is the only reading that distinguishes
|
||||
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
|
||||
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
|
||||
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
|
||||
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
|
||||
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
|
||||
inverse transform passes every test taken at the identity view.
|
||||
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
|
||||
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
|
||||
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
|
||||
input: `(want non-empty 0)` then `(want empty 1)`.
|
||||
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
|
||||
then `app.math_dialog` read back. `STILL SET` is the regression.
|
||||
|
||||
Two things the probe deliberately cannot assert, and which are therefore stated as design
|
||||
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
|
||||
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
|
||||
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
|
||||
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
|
||||
|
||||
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
|
||||
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
|
||||
|
||||
## Documentation
|
||||
|
||||
Three places, following the project's existing split:
|
||||
|
||||
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
|
||||
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
|
||||
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
|
||||
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
|
||||
the index-remap set named, and a note that the `Cancel` path is now asserted.
|
||||
- **`README.md`** — a subsection under the math channels describing the editor as it now
|
||||
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
|
||||
built and why" lives.
|
||||
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
|
||||
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
|
||||
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
|
||||
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
|
||||
parse renders a stand-in.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
|
||||
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
|
||||
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
|
||||
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
|
||||
name field with a wire selected.
|
||||
- **The wheel controller fighting a future scrolled window.** There is none and this design
|
||||
removes the reason for one, which is worth stating so a later "wrap the canvas in a
|
||||
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
|
||||
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
|
||||
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
|
||||
graph, which removes the ordering question rather than answering it.
|
||||
@@ -0,0 +1,437 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# Three things a bench scope has and udpscope does not: qualified triggers, measurement statistics, and a search over the buffer
|
||||
|
||||
udpscope triggers on one thing — a level crossing — reports each measurement as a single
|
||||
number with no sense of how it has varied, and offers no way to ask where in the retained
|
||||
buffer something happened. A survey of what bench scopes and the open-source viewers provide
|
||||
turned up a longer list than this; it was narrowed deliberately, and what was cut is named in
|
||||
**Scope** below.
|
||||
|
||||
The three features chosen are not independent. A qualified trigger condition and a search over
|
||||
the buffer are the *same question* asked of live data and of recorded data, so they are built
|
||||
on one evaluator rather than two, and the probe that proves they agree is part of the work.
|
||||
Measurement statistics are separate but cheap: the pane's recompute pass already produces the
|
||||
number a statistic accumulates.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- A shared, allocation-free `EventDetector` extracted out of `TriggerEngine`, carrying four
|
||||
condition types: edge (as now), pulse width (with glitch as a comparison, not a fifth type),
|
||||
window, and dropout.
|
||||
- The trigger panel's new type row and the parameter rows each type needs.
|
||||
- Per-measurement statistics — count, mean, standard deviation, min, max — accumulated one
|
||||
observation per recompute, with a pane-wide toggle and a Reset.
|
||||
- A `Search` section in the trigger panel that scans the retained ring for the trigger's own
|
||||
condition, marks the hits, and navigates them by reusing the existing pause.
|
||||
- Four new probes, and the mock waveforms they need.
|
||||
- `SPECS.md` is not changed; `README.md` and `docs/claude/` are.
|
||||
|
||||
**Out, and deliberately:**
|
||||
|
||||
- **Reference waveforms** — declined explicitly.
|
||||
- **PNG export** — the `.npy`/`.csv` export covers the need that matters; a screenshot is the
|
||||
window manager's job.
|
||||
- **FFT / spectrum** — needs a second `plot::Figure` with its own x units, which is a project
|
||||
of its own and not a feature of this one.
|
||||
- **Byte-bounded event history** — a second retention policy beside the ring's, with its own
|
||||
eviction. Out for the same reason.
|
||||
- No new measurement types. No change to `measure_scan_buckets()` /
|
||||
`measure_accumulate_bucket()` or to the hysteresis band. No change to the decimation plan, the
|
||||
partition, or the two-thread split.
|
||||
|
||||
## Global constraints
|
||||
|
||||
These bind every task below and are not restated in each one.
|
||||
|
||||
- **Two threads only.** `EventDetector::feed()` runs on the producer inside
|
||||
`Acquisition::on_data()`, under `ScopeModel::mutex()`. It allocates nothing, throws nothing,
|
||||
does no I/O, and is `noexcept`. The statistics accumulator and the search driver run on the
|
||||
GTK main thread.
|
||||
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||
- **Stream seconds, end to end.** Nothing on the trigger or search path converts a span to a
|
||||
sample count, and nothing introduces a samples-per-second estimate. That conversion is the
|
||||
documented cause of the "the post window is empty" defect
|
||||
(`docs/claude/invariants-trigger-and-pinning.md`).
|
||||
- **The detector is fed the stored (decimated, averaged) value**, never the wire value — an
|
||||
event found on a sample the buffer does not hold cannot be cut from it.
|
||||
- **A pinned axis is pinned from the snapshot's own stamps, never from the request.** Applies to
|
||||
every pause the search publishes.
|
||||
- **There is no unit test suite.** Every assertion is a probe line on stderr, driven through the
|
||||
real widgets, read against the documented baselines: the documented build-warning count and
|
||||
zero `Gtk-CRITICAL`.
|
||||
- **No figure is written into a document unless it was measured on this machine.** Costs are
|
||||
printed by probes and quoted from those runs.
|
||||
- **Run one probe at a time.** The only exception is the search probe's internal
|
||||
scan-versus-armed pairing, which is one probe.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/condition.hpp` / `.cpp` | **New.** `EventCondition`, `TriggerType`, `WidthCmp`, and `EventDetector` — the whole per-sample condition state machine and nothing else. No model, no GTK, no allocation. |
|
||||
| `src/trigger.hpp` / `.cpp` | `TriggerConfig` grows an `EventCondition`. `TriggerEngine` keeps arming, the hold-off, the pre-window gate, the post window and the mode, and delegates detection to `EventDetector`. |
|
||||
| `src/search.hpp` / `.cpp` | **New.** `BufferSearch` — the chunked scan driver over the retained ring, the hit list, the cursor into it, and the staleness state. Owns no widgets. |
|
||||
| `src/measure.hpp` / `.cpp` | `MeasureStatsAccum` (Welford) beside the existing result types. |
|
||||
| `src/plot_host.hpp` / `.cpp` | `kSearchMarks` cursors per row, created in `install()`, placed by a new `set_search_marks()`. |
|
||||
| `src/ui.cpp` | The trigger panel's type and parameter rows, the `Search` expander, the statistics toggle and second line, the chunk pump in the frame tick, and the four probes. |
|
||||
| `src/layout_store.hpp` / `.cpp` | One new `LayoutState` field: the statistics toggle. The trigger is not persisted today and is not persisted by this design. |
|
||||
| `tools/mock_udpstreamer.py` | `--glitch-at` / `--glitch-width` / `--quiet-at`. |
|
||||
| `README.md`, `docs/claude/*` | What was built, and the invariants it adds. |
|
||||
|
||||
`src/condition.*` and `src/search.*` are new files rather than additions to `src/trigger.cpp`
|
||||
and `src/ui.cpp` for two reasons: the detector must be includable by the search without
|
||||
dragging in the engine's arming state, and `src/ui.cpp` is already the largest file in the
|
||||
project.
|
||||
|
||||
## 1. The shared event condition
|
||||
|
||||
### What is extracted
|
||||
|
||||
`TriggerEngine::process()` (`src/trigger.cpp:38-106`) currently does three jobs. Lines 78-84
|
||||
detect a crossing; line 92 gates it on the pre-trigger window; lines 42-75 run the
|
||||
pending/post-window/hold-off/mode machine. Only the first is what a search means by "a
|
||||
condition", so only the first moves.
|
||||
|
||||
```cpp
|
||||
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||
|
||||
struct EventCondition {
|
||||
TriggerType type = TriggerType::Edge;
|
||||
double level = 0.0;
|
||||
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||
bool operator==(const EventCondition&) const noexcept;
|
||||
};
|
||||
|
||||
class EventDetector {
|
||||
public:
|
||||
/// Adopts the condition. Resets the state iff the condition differs.
|
||||
void configure(const EventCondition& c) noexcept;
|
||||
void reset() noexcept;
|
||||
/// One sample, in stream order. Returns the event's own stream time, or NaN for no event.
|
||||
double feed(double value, double t) noexcept;
|
||||
};
|
||||
```
|
||||
|
||||
`feed()` returns a **time** rather than a bool because for two of the four types the event's
|
||||
instant is not the sample being looked at: a pulse width is only known at the pulse's closing
|
||||
edge, and a dropout fires on an ordinary sample nowhere near an edge.
|
||||
|
||||
`TriggerEngine::process()` then reads:
|
||||
|
||||
```cpp
|
||||
const double event_t = detector_.feed(value, now_s);
|
||||
if (!std::isfinite(event_t)) return false;
|
||||
if (event_t - oldest_s_ < pre_seconds_) return false; // the gate, now on the event's own t
|
||||
last_trigger_s_ = event_t;
|
||||
pending_ = true;
|
||||
pending_start_s_ = event_t;
|
||||
```
|
||||
|
||||
Note the gate and `pending_start_s_` both move from `now_s` to `event_t`. For `Edge` they are
|
||||
the same value and the behaviour is bit-identical to today; for a long qualifying pulse they
|
||||
differ, and using `now_s` would measure the post window from the wrong instant.
|
||||
|
||||
`TriggerEngine::configure()` keeps its name-keyed reset exactly as it is
|
||||
(`docs/claude/invariants-trigger-and-pinning.md`) and additionally calls
|
||||
`detector_.configure(cfg.condition)`, which resets the detector only when the condition
|
||||
actually changed — a condition re-applied every packet must not restart the state machine, or a
|
||||
pulse-width condition could never see a pulse end.
|
||||
|
||||
### The four types
|
||||
|
||||
| Type | Fires when | The instant it calls t0 |
|
||||
| --- | --- | --- |
|
||||
| `Edge` | `level` is crossed in `edge`'s direction | the crossing sample |
|
||||
| `PulseWidth` | a pulse — a crossing in `edge`'s direction, then the crossing back — whose duration satisfies `cmp` against `width_s` (and `width2_s` for `Within`/`Outside`) | **the closing edge**: the width is not known before it |
|
||||
| `Window` | the signal enters or leaves `[min(level,level2), max(level,level2)]`; `Rising` = enters, `Falling` = leaves, `Both` = either | the crossing sample |
|
||||
| `Dropout` | `level` has not been crossed for longer than `timeout_s` | the first sample at which the timeout is observed |
|
||||
|
||||
**Glitch is `PulseWidth` with `cmp = Less`** and is not a separate type. That is how bench
|
||||
scopes spell it, and one state machine cannot disagree with itself about where a pulse ended.
|
||||
The panel may label the preset "glitch".
|
||||
|
||||
`Dropout`'s t0 is the sample at which the expiry is *observed*, not the exact expiry instant,
|
||||
for the same reason every readout in this app quotes a stored sample: t0 has to be a sample the
|
||||
buffer holds, because the cut is a binary search over the time ring and the axis is pinned to
|
||||
what the cut got.
|
||||
|
||||
**A zero-width band is refused by the panel, not special-cased in the detector.** The band test
|
||||
is `v >= lo && v <= hi` and the event is a change of side, so with `level == level2` the signal
|
||||
is "inside" only on exact float equality — which a crossing essentially never lands on, making
|
||||
the condition one that silently never fires. That is a worse failure than a refusal, and it
|
||||
cannot be fixed by special-casing the detector into an edge trigger: the two state machines
|
||||
answer different questions and conflating them is the divergence this design exists to prevent.
|
||||
So the second level spin is clamped to differ from the first by at least its own increment, the
|
||||
panel says why, and `EventDetector` keeps one unconditional band test.
|
||||
|
||||
### Panel and session
|
||||
|
||||
The trigger panel gains a **type** combo above the existing rows, and the rows below it change
|
||||
with the type: `Edge` shows the level, edge and pre-% rows it shows now; `PulseWidth` adds a
|
||||
comparison combo and one or two width spins; `Window` turns the single level spin into two;
|
||||
`Dropout` replaces the width with a timeout. Hold-off, pre-% and Normal/Single are common to
|
||||
all four and do not move.
|
||||
|
||||
No plot instrument is added. The trigger level has never been drawn on the plot — it is a spin
|
||||
button (`src/ui.cpp:135`, `:1604`) — so a band needs no second ruler.
|
||||
|
||||
`TriggerConfig` gains one `EventCondition` member; its `level` and `edge` replace the
|
||||
`TriggerConfig` fields of those names, and every reader moves with them.
|
||||
|
||||
**The condition is not persisted, because the trigger is not persisted.** `LayoutState`
|
||||
(`src/layout_store.hpp:19-63`) carries the window, rows, calibration, colours, enabled signals,
|
||||
the source, the math graphs and the measurements — and no trigger field at all; the trigger is
|
||||
built from `--holdoff` and the panel's own defaults on every launch. Adding persistence for it
|
||||
is a separate feature nobody asked for, and a session that silently restored an armed
|
||||
pulse-width condition would be a surprise rather than a convenience. The only session key this
|
||||
design adds is the measurements pane's **statistics toggle**, which belongs beside the
|
||||
`measures` it governs.
|
||||
|
||||
The level's existing default — the middle of the signal's declared range until the user edits it
|
||||
(`src/ui.cpp:2016-2054`) — applies to `Window` as a band about that middle, and `level2`
|
||||
defaults with it.
|
||||
|
||||
## 2. Measurement statistics
|
||||
|
||||
### What is accumulated
|
||||
|
||||
Not the samples: `Average` is already the mean of every visible sample. A statistic is of the
|
||||
**measurement's own reported value over time** — count, mean, standard deviation, min and max of
|
||||
every value that measurement has produced since the last reset.
|
||||
|
||||
```cpp
|
||||
/// The variation of one measurement's own value over time. Fixed size, no allocation: it is
|
||||
/// folded in the pane's existing recompute pass, which must not grow a second pass.
|
||||
struct MeasureStatsAccum {
|
||||
std::uint64_t n = 0;
|
||||
double mean = 0.0; ///< Welford's running mean
|
||||
double m2 = 0.0; ///< Welford's sum of squared deviations
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
void add(double v) noexcept;
|
||||
void reset() noexcept;
|
||||
double sd() const noexcept; ///< sqrt(m2 / (n - 1)), 0 for n < 2
|
||||
};
|
||||
```
|
||||
|
||||
Welford rather than `sum` and `sum_of_squares`: on a value with a large offset — a 3.3 V logic
|
||||
level whose σ is a few millivolts — the naive form loses the variance entirely to cancellation
|
||||
in `Σv²`.
|
||||
|
||||
### One observation per recompute
|
||||
|
||||
`update_measures()` is throttled deliberately (`src/ui.cpp:3141-3162`): at most one pass per
|
||||
`kMeasureRefreshUs` while the window scrolls, four times that when a pass is heavy, and exactly
|
||||
**once when the window settles** — the `!changed` early return at `:3153` is what makes a pinned
|
||||
window recompute once rather than every frame. The accumulator takes one observation per pass,
|
||||
so:
|
||||
|
||||
- A scrolling live view contributes on the order of ten observations a second.
|
||||
- A paused view, a held event or a pinned window contributes **one**.
|
||||
- `n` is therefore a count of *recomputes* — not of samples, frames or seconds. The pane labels
|
||||
it `n` and its tooltip says so. Presenting it as a sample count would be a figure the user
|
||||
acts on and that is false.
|
||||
|
||||
Two exclusions, each of which would corrupt the statistic silently:
|
||||
|
||||
- An **invalid** result (`--`) contributes nothing, to neither `n` nor the extremes.
|
||||
- The **`measure_last_valid` fallback** (`src/ui.cpp:3363`) is **not** accumulated. It exists so
|
||||
a level mark does not blink off and is by construction a repeat of an observation already
|
||||
counted; folding it in pulls the mean toward whatever preceded a dropout and collapses σ.
|
||||
|
||||
### Reset
|
||||
|
||||
An explicit **Reset** button in the pane clears every accumulator. Three changes clear them
|
||||
implicitly, because each one makes the values before and after a different population:
|
||||
|
||||
- the measurement is edited (signal, type or options);
|
||||
- a CONFIG arrives — the signal list is rebuilt and a name may now resolve elsewhere;
|
||||
- the display window changes — a mean over 2 s and a mean over 600 s are different statistics.
|
||||
|
||||
Deliberately **not** cleared by a pause, a resume, an event latch, or a pan/zoom within the
|
||||
window. Each of those contributes its one settled observation.
|
||||
|
||||
Accumulators are not persisted. A statistic over a session that has ended is not a statistic;
|
||||
the **toggle's state is** persisted.
|
||||
|
||||
### Display
|
||||
|
||||
One `Statistics` check button at the top of the measurements pane. Off — the default — the pane
|
||||
is exactly as it is now. On, every row grows a second line beneath it, dimmed and smaller:
|
||||
|
||||
```
|
||||
● ● frequency 5.0003 Hz edit
|
||||
x̄ 5.0001 σ 0.0004 ▾ 4.9989 ▴ 5.0012 n 143
|
||||
```
|
||||
|
||||
Pane-wide rather than per-measurement, so that the second lines align and are comparable, and
|
||||
because the per-measurement dialog is already the busiest surface in the app.
|
||||
|
||||
The second line is a `GtkLabel` in a vertical box wrapping the existing horizontal row, built in
|
||||
`measures_row()` (`src/ui.cpp:2495`) and shown or hidden by the toggle — the toggle must **not**
|
||||
call `rebuild_measures_pane()`, which drops and recreates every label including the ones the
|
||||
frame tick holds pointers to. All five numbers go through the same `format_measure()` the
|
||||
value above them uses, so units and significant figures agree by construction.
|
||||
|
||||
## 3. Search and navigate
|
||||
|
||||
### What it searches, and with what
|
||||
|
||||
A `Search` expander at the bottom of the trigger panel, searching **the trigger's own
|
||||
`EventCondition`** read straight off `TriggerConfig`. There is no second condition UI: a
|
||||
condition is set up once and can either be armed to capture live or scanned for over the buffer
|
||||
already held. That is what the shared evaluator buys, and it gives the search all four types for
|
||||
free.
|
||||
|
||||
It runs on the **trigger's channel** over the **retained ring** — not the visible window, which
|
||||
the user can already read by looking.
|
||||
|
||||
### The scan is exact, chunked, and progressive
|
||||
|
||||
It feeds the same `EventDetector`, sample by sample, in stream order, raw. No bucket-skipping: a
|
||||
level-0 bucket whose `[lo,hi]` does not straddle the level cannot hold a crossing, so skipping
|
||||
it is tempting, but the detector is a sequential state machine and synthesising its previous
|
||||
value across a skipped span is precisely how the search and the live trigger would come to
|
||||
disagree about where an edge was. The shared evaluator exists to make that impossible.
|
||||
|
||||
A raw pass over the whole ring cannot be one pass under one lock — that holds
|
||||
`ScopeModel::mutex()` long enough to starve the producer. `BufferSearch` therefore advances in
|
||||
**bounded chunks pumped from the frame tick**, taking and releasing the lock per chunk, the same
|
||||
shape `update_measures()` uses to materialise its window in chunks. The detector's state carries
|
||||
across chunks because it is sequential. The panel shows progress and a Cancel.
|
||||
|
||||
Two limits, both reported in the panel rather than hidden:
|
||||
|
||||
- **The ring keeps filling during the scan** — a pause freezes the view, not the acquisition.
|
||||
Each chunk re-clamps to the ring's current base; if the base has passed the chunk's start the
|
||||
scan stops and the panel says so (`searched 412 s of 600 s — the oldest data was overwritten
|
||||
while scanning`). Reachable only at high rates on a small buffer.
|
||||
- **The hit list is capped** at `kSearchHitsMax = 10000`, and the panel says
|
||||
`stopped at 10000 matches`. A condition matching every sample is a mis-set condition; the
|
||||
answer is a message, not millions of doubles.
|
||||
|
||||
### The hits, and navigating them
|
||||
|
||||
Hits are a `std::vector<double>` of stream times, each the t0 its condition defines. The panel
|
||||
shows `147 matches` with `◀ prev`, `next ▶` and `3 / 147`, with key bindings while the panel has
|
||||
focus.
|
||||
|
||||
Navigation reuses the existing pause. Jumping to hit `t` pauses if not already paused and calls
|
||||
the primitive that is already there (`src/ui.cpp:2188-2199`):
|
||||
|
||||
```cpp
|
||||
model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
|
||||
if (auto snap = model->pause_view(); snap && !snap->stamps.empty())
|
||||
rows->pin_pause_window(snap->stamps.front(), snap->stamps.back());
|
||||
```
|
||||
|
||||
`w` is the current display window. `x_offset = t` puts the hit at **x = 0**, exactly where a
|
||||
held event's edge sits, so the two ways of looking at an event read alike and the x-label logic
|
||||
needs no new case. The pin comes off the snapshot's own stamps and never off the request: a hit
|
||||
near the oldest retained sample yields a shorter cut, and pinning the request leaves an empty
|
||||
left margin that a zoom-in walks into.
|
||||
|
||||
Resuming or clearing the search resumes and unpins exactly as the pause button does. The hit
|
||||
list survives a resume — it describes data the ring may still hold — but goes stale, so the
|
||||
panel marks its age, and any structural rebuild (a CONFIG, an enable/disable, a calibration
|
||||
change, or a window change that moves `time_stride`) clears it outright, because those drop the
|
||||
history it refers to.
|
||||
|
||||
### Marks
|
||||
|
||||
Cursors are not dynamic: `RowSet` creates them up front in fixed arrays
|
||||
(`kMeasureMarks = 6`, `src/plot_host.hpp:55`) because a `Plot` cannot give one up. So this adds
|
||||
`kSearchMarks = 16` cursors per row, created in the same `install()` loop
|
||||
(`src/plot_host.cpp:185-188`) and taking the same read-only dashed 0.75 px stroke the measure
|
||||
marks take, parked when unused.
|
||||
|
||||
Every frame, the hits inside the visible window are placed on them, nearest to the view's centre
|
||||
first, with the current hit in a distinct colour. When more hits are in view than there are
|
||||
marks the panel reads `16 of 38 marks shown — zoom in to see the rest`, which is a true
|
||||
statement about the picture rather than a silent truncation. Like the measure marks they are
|
||||
re-placed every frame, which is what makes them read-only: a gesture moves one and this puts it
|
||||
back.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
Four probes, each scheduled by an env var, each driving the real widgets, each printing tagged
|
||||
lines to stderr. Added to `docs/claude/verification.md` and `docs/claude/probe-readings.md` with
|
||||
the readings a correct run gives — measured, never assumed.
|
||||
|
||||
**The numbers in the sample lines below, and in the pane mock-up above, show the line's *shape*
|
||||
only.** None of them has been measured; nothing may copy them into a document as a reading. The
|
||||
figures that go into `verification.md` come from a run.
|
||||
|
||||
**`UDPSCOPE_PROBE_CONDITION_MS`** — for each of `Edge`, `PulseWidth` with `Less`, `PulseWidth`
|
||||
with `Greater`, `Window` and `Dropout`: set the type and its parameters through the panel, arm,
|
||||
wait for the latch, print
|
||||
|
||||
```
|
||||
[condition-probe] type=pulsewidth cmp=less width=0.0005 t0=4.3021 want=4.3020 ok=1
|
||||
```
|
||||
|
||||
`want` comes from the mock's injection schedule. `ok=1` is the reading.
|
||||
|
||||
**`UDPSCOPE_PROBE_STATS_MS`** — add a `frequency` measurement on the mock's sine, turn the
|
||||
statistics toggle on, let several recomputes land, print
|
||||
|
||||
```
|
||||
[stats-probe] n=14 mean=5.0001 sd=0.0004 lo=4.9989 hi=5.0012 toggle=1 line2=1
|
||||
```
|
||||
|
||||
then press Reset and print it again: `n=0` is the assertion. It must also hold a pause for a
|
||||
stretch and show `n` advancing by **exactly 1** across it — that is what distinguishes
|
||||
accumulating per recompute from accumulating per frame. With the `measure_last_valid` fallback
|
||||
wrongly accumulated, `n` climbs through an injected dropout while the value does not change and
|
||||
`sd` collapses; that is the second defect this probe exists for.
|
||||
|
||||
**`UDPSCOPE_PROBE_SEARCH_MS`** — configure a glitch condition, scan to completion against the
|
||||
injected pulse train, print
|
||||
|
||||
```
|
||||
[search-probe] matches=7 first=1.3020 last=6.8020 scanned=8.21/8.21 s truncated=0 capped=0
|
||||
[search-probe] jump 1/7 t=1.3020 axis=[-1.0000..1.0000] snap=[-1.0000..1.0000] inside=1 x0_hit=1
|
||||
```
|
||||
|
||||
for three successive `next` presses. `matches` is asserted against the injection count,
|
||||
`inside=1` is the pause-pin invariant, `x0_hit=1` is the hit sitting at x = 0.
|
||||
|
||||
**The cross-check that makes the shared evaluator a claim and not a hope.** The same probe run,
|
||||
same condition, same mock, **armed** instead of searched: the first latch's t0 must equal the
|
||||
scan's `first` exactly.
|
||||
|
||||
```
|
||||
[search-probe] armed t0=1.3020 vs scanned first=1.3020 agree=1
|
||||
```
|
||||
|
||||
A divergence between the live and the offline path appears here and nowhere else.
|
||||
|
||||
**Costs are printed, not asserted**, because an unmeasured figure drifts: `scan cost: N chunks,
|
||||
X ms total, worst chunk Y ms` — the **worst chunk** is the invariant worth watching, since it is
|
||||
what the producer waited on; the total is allowed to be long. Statistics accumulation is folded
|
||||
into the existing `measure cost:` line.
|
||||
|
||||
**Mock additions:** `--glitch-at <s>` / `--glitch-width <s>` inject a narrow pulse into a pulse
|
||||
train at a known instant, and `--quiet-at <s>` stops a signal crossing its level from a known
|
||||
instant. Both are permanent regression cases, like `--time-glitch-at`.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`TriggerConfig`'s `level`/`edge` move into `EventCondition`**, so every reader changes —
|
||||
the panel, the probes and the status line. Mechanical, but wide, and the
|
||||
mitigation is that `Edge` behaviour must be bit-identical afterwards: the existing trigger
|
||||
probes are the regression test and are run before anything new is added.
|
||||
- **The scan's worst chunk is a latency the producer pays.** The chunk size is the one knob, it
|
||||
is picked from the measured figure, and the probe prints the worst chunk every run.
|
||||
- **`kSearchMarks = 16` is a fixed cost in cursors per row** on every plot whether or not a
|
||||
search is running, as `kMeasureMarks = 6` already is.
|
||||
@@ -0,0 +1,327 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# A late packet leaves a hole it could have filled, and the clock it would be filed under is the wrong one
|
||||
|
||||
Two requests, and they are one design.
|
||||
|
||||
The first is that a reordered packet currently reads as permanent damage. `udps_client.c:504-514`
|
||||
orders the stream by the wrapping `uint32` counter and discards anything that arrives behind the
|
||||
newest counter — `stats.stale_packets++; return 0` — *before* spending anything on the payload.
|
||||
The samples are gone, the span they should have filled stays empty, and udpscope never learns
|
||||
there was a span at all. The asked-for remedy is to leave a hole of
|
||||
`<N samples> × <LAST_PACKET_ID − NEW_PACKET_ID − 1>`, mark it invalid, and fill it when the
|
||||
missing packets arrive.
|
||||
|
||||
The second is `todo.md` item 2: a relative-time mode in which the plot's x comes only from the
|
||||
declared sample rate and the packet number, with absolute stream time kept for export.
|
||||
|
||||
They are one design because **the counter is what makes a hole addressable**. A reserved hole has
|
||||
to be a known number of samples at a known position, and a late packet has to compute which
|
||||
position it belongs to. Absolute stamps cannot do either — a lost packet's stamps were never
|
||||
received, so its extent in time is a guess, and the guess is what the glitch bridge at
|
||||
`src/acquisition.cpp:274-292` is already making. The counter gives the extent exactly. Relative
|
||||
timing is not a cosmetic preference that happens to be requested at the same time; it is the
|
||||
coordinate system in which resequencing is arithmetic rather than inference.
|
||||
|
||||
Two facts from the existing code shape almost every decision below, and both say the work is
|
||||
smaller than it looks:
|
||||
|
||||
- **`frame->counter` and `frame->lost` are read nowhere in udpscope.** `frame.lost` is set by the
|
||||
client at `udps_client.c:560` and appears in `src/acquisition.cpp` only inside comments
|
||||
(`:309`, `:331`, `:336`). The client has been reporting the exact gap width for the life of the
|
||||
project and nothing has ever consumed it. Neither input needs inventing.
|
||||
- **`clamp_deadline()` already exists** (`udps_client.c:1102`), documented as *"Clamps `budget` so
|
||||
a pending deadline is not slept through"* and already used for the keepalive and the silence
|
||||
timeout. The reorder deadline is a third call to it.
|
||||
|
||||
## What was decided, and the constraint that decided it
|
||||
|
||||
The latency budget is **20 ms**, maximum. That one number settles the shape of the whole receive
|
||||
path, because it is a *time* budget and not a packet count: at 1000 pkt/s it is twenty packet
|
||||
spans of reorder depth, at 10 pkt/s it is a fifth of one. A hold policy counted in packets would
|
||||
either be useless at high rates or add 400 ms at low ones. A policy counted in wall time works at
|
||||
every rate, because network reorder is measured in milliseconds regardless of how fast the
|
||||
producer sends.
|
||||
|
||||
It also means a *contiguous* arrival must be released immediately. The 20 ms is spent only when
|
||||
there is a gap to wait on, never on the common case.
|
||||
|
||||
Past the window, a committed hole is still **backfilled for values only**. Math channels and the
|
||||
trigger are not replayed. A math channel takes NaN in and gives NaN out, so after a repair the
|
||||
raw trace is whole and the math trace derived from it keeps its gap. That is a deliberate,
|
||||
documented state, not an oversight: the display then says *"this region arrived too late to be
|
||||
part of the computation"*, which is true and is cheaper than any mechanism that could make it
|
||||
false.
|
||||
|
||||
Resequencing and hole reservation run in **both** time modes. In relative mode a hole's stamps
|
||||
are exact; in stream mode they are the declared cadence continued from the last good stamp, which
|
||||
is the existing bridge mechanism reused. Relative timing is therefore an independent toggle a user
|
||||
flips for jitter, not a prerequisite for recovery.
|
||||
|
||||
A hole is surfaced two ways: **status-bar counters** and a **shaded band** over the invalid span.
|
||||
A NaN run already breaks the polyline, but a one-packet hole at a 10 s window is sub-pixel, and a
|
||||
gap nobody can see is not a report.
|
||||
|
||||
## Relative timing
|
||||
|
||||
`build_stamps()` today picks its clock *by inspection*: `time_source_` latches 1 for the declared
|
||||
time signal or 2 for the arrival clock and never flips back (`src/acquisition.cpp:357-367`).
|
||||
Relative timing is not a third thing to infer. It is a user-selected mode that bypasses the
|
||||
inference: `--relative-time` on the command line, a toggle in the UI, persisted in the session
|
||||
file.
|
||||
|
||||
### The stamp
|
||||
|
||||
```
|
||||
stamps_[k] = double(slot_base_ + k) / rate
|
||||
```
|
||||
|
||||
`slot_base_` is a running `u64` of samples seen. Each packet the counter reports missing advances
|
||||
it by `batch`, **before** the frame's own samples are stamped; the delivered frame then advances it
|
||||
by its own `n`. The order is what puts the hole ahead of the data that revealed it.
|
||||
|
||||
This is the requested `counter × batch + sample_index` with the origin left at counter zero, but
|
||||
**accumulated rather than multiplied**, and the difference earns three things:
|
||||
|
||||
- `batch` is then only ever needed to size what is *absent*. That is exactly the
|
||||
`<n_samples_per_frame>` of the original formula, and it is the only place the value is load
|
||||
bearing.
|
||||
- The `uint32` wrap costs nothing. The counter is consumed only as a signed delta — the same
|
||||
arithmetic `udps_client.c:505` already performs — so no unwrapping bookkeeping is needed.
|
||||
- A frame whose `n` disagrees with the established `batch` cannot silently shift the whole
|
||||
timeline, because the timeline is built from the `n` values actually seen.
|
||||
|
||||
`batch` is established from the first DATA frame and held for the life of the stream. A later
|
||||
frame disagreeing is a genuine inconsistency in the producer, not a loss, so it warns once rather
|
||||
than re-deriving.
|
||||
|
||||
### The rate is the declared one, and only the declared one
|
||||
|
||||
`build_stamps()` currently overrides a declared cadence with the arrival-measured one when the two
|
||||
disagree by more than ±25% (`src/acquisition.cpp:152-158`), because a CONFIG declaring 1 MS/s
|
||||
while delivering a hundred samples every fifth of a second reads as a staircase in the x column.
|
||||
Relative mode must **not** do this. Discarding arrival timing is the entire point of the mode. The
|
||||
override stays live in stream mode and is skipped in relative mode.
|
||||
|
||||
### The constant offset is already absorbed
|
||||
|
||||
`src/acquisition.cpp:555` stamps `rel = stamps_[k] - epoch`, and the `t0 =` caption names the
|
||||
epoch. A counter that happens to read 500 000 on connect therefore shows up as an epoch value, not
|
||||
as a 50 000 s axis. No origin subtraction is needed in the formula itself.
|
||||
|
||||
### The x axis needs no change at all
|
||||
|
||||
`RowSet::update_time_labels()` already goes to a relative label form on the tick step against the
|
||||
label resolution at the axis' magnitude, with the `t0 =` caption naming the absolute anchor
|
||||
(`docs/claude/invariants-time-axis-and-window.md:34`). "Counting backwards, like it is now" is
|
||||
what that already does. Relative mode changes *where a stamp comes from* and nothing in the label
|
||||
layer.
|
||||
|
||||
### What relative mode makes unreachable
|
||||
|
||||
The degeneracy guard, the implausible-jump test, `kFailureLimit`, the bridge and the monotonic
|
||||
clamp are all dead in relative mode: the stamps are monotone by construction, so there is no bad
|
||||
stamp to detect and nothing to clamp. (`kGlitchPackets` itself survives — not as the threshold of
|
||||
that test, which is unreachable, but as the reservation cap below, where its value and its
|
||||
reasoning carry over unchanged.)
|
||||
|
||||
That is not tidiness, it is a fix. Measured last session: a loss burst past `kGlitchPackets` (256)
|
||||
is misread as a producer clock glitch; each bridge then advances the anchor by *one* packet span
|
||||
while the true timeline is 300 ahead, so the next four packets fail too, `kFailureLimit` trips,
|
||||
and the whole stream is thrown onto the arrival clock — the exact failure the bridge was added to
|
||||
prevent (`--drop-burst 300` reads
|
||||
`jumped implausibly far ahead ... (1.299..1.29999 against a previous 0.99899)` then
|
||||
`timing samples by arrival instead`). In relative mode a 300-packet gap is a known quantity and
|
||||
cannot be misdiagnosed.
|
||||
|
||||
### Absolute time for export
|
||||
|
||||
`todo.md` requires absolute stream time to remain available for export. There is exactly one f64
|
||||
time ring per `BufferStore`, shared by every channel of that buffer, and relative mode takes it.
|
||||
Export therefore needs a **second** f64 ring carrying the absolute stamp under the same group-mean
|
||||
rule, written in **relative mode only** and read by export and by nothing else — eight bytes per
|
||||
stored slot. The absolute ring is not kept unconditionally; it is budgeted along with the main
|
||||
ring and the value rings, which is why it halves the retained span rather than costing a
|
||||
proportional fraction (see SPECS.md for the measured figures and the power-of-two explanation).
|
||||
|
||||
This cost is paid rather than avoided. Without it, selecting relative mode silently degrades the
|
||||
one output `todo.md` says must keep absolute time.
|
||||
|
||||
The measured history cost: **83.886 s** in stream mode and **41.943 s** in relative mode, at
|
||||
100 kS/s, 4 channels, `--buffer-mb 200`. The plan's ~22% figure and the per-byte 75% figure are
|
||||
both wrong because `Partition::time_capacity` is floored to a power of two.
|
||||
|
||||
## The receive path
|
||||
|
||||
### In the client: a reorder ring of reassembled payloads
|
||||
|
||||
Every `udps_frame_t` sets `frame.signals = c->sigs; frame.values = c->vals`
|
||||
(`udps_client.c:566-567`) — one client-owned decode buffer, reused for every packet. A frame is
|
||||
therefore valid only inside the callback, and holding one requires a copy *wherever* the holding
|
||||
happens. The client has no advantage there.
|
||||
|
||||
It has a different one: it still holds the **raw datagram**. Resequencing undecoded wire bytes is
|
||||
strictly cheaper than resequencing decoded doubles — an `f32` or `i16` payload is two to four
|
||||
times smaller than what `c->valbuf` would hold — and the bytes exist nowhere else. That is why
|
||||
this sits in the client and not in `Acquisition`.
|
||||
|
||||
The hook is the `decode_data()` call site at `udps_client.c:599`: after fragment reassembly, which
|
||||
already tolerates arrival order through the slot table, and before decode. Each held entry carries
|
||||
the payload bytes, their length, the counter and `recv_time`.
|
||||
|
||||
The policy, against `next_counter = last_delivered + 1`:
|
||||
|
||||
| arrival | action | added latency |
|
||||
| --- | --- | --- |
|
||||
| `== next_counter` | decode and deliver now, then drain held entries that became contiguous | **none** |
|
||||
| `> next_counter` | copy into the ring; set the gap deadline to `now + 20 ms` if not already set | the newer packet waits |
|
||||
| `< next_counter` | the gap is already committed — decode and deliver, flagged **late** | n/a |
|
||||
| deadline fires | give up on the missing counters, advance `next_counter` to the lowest held entry, drain in order | capped at 20 ms |
|
||||
|
||||
The last row is where `stats.stale_packets++; return 0` at `:506-509` is replaced: a late packet
|
||||
is delivered flagged instead of discarded, which is the single change that makes backfill possible
|
||||
at all.
|
||||
|
||||
The deadline is served by a third `clamp_deadline()` call on the `select()` budget
|
||||
(`udps_client.c:1145-1152`). Without it, a held packet on a quiet stream would wait for the next
|
||||
arrival — 100 ms at 10 pkt/s — and blow the budget.
|
||||
|
||||
Two bounds on the ring, because 20 ms is a different number of packets at every rate: a fixed
|
||||
entry count and a fixed byte budget. Overflow commits the gap early rather than growing, and
|
||||
increments its own counter, so an undersized window is visible rather than silent.
|
||||
|
||||
The window itself is `udps_config_t::reorder_window_s`, defaulting to `0.020`, along
|
||||
`recv_buffer_bytes` and `silence_timeout_s` (`udps_client.c:982`). Zero disables resequencing and
|
||||
restores today's discard-on-late behaviour exactly, which is what makes the change bisectable
|
||||
against a reported regression. The policy value is udpscope's; the mechanism is the client's.
|
||||
|
||||
### In `Acquisition`: reservation
|
||||
|
||||
On a frame with `lost > 0`, before pushing the frame's own samples, run `lost × batch` NaN samples
|
||||
through the **existing** per-sample loop at `src/acquisition.cpp:553`.
|
||||
|
||||
That reuse is the design. `tick_` advances, so `tick % g == g - 1` keeps firing on the same phase
|
||||
and the decimation grid does not shift; the group mean still lands on the mean stamp; and
|
||||
`MinMaxPyramid::append` sees NaN, which its existing non-finite contract already handles as
|
||||
*advances the positional count, excluded from every aggregate*. Nothing bespoke is needed for
|
||||
decimation, stamping or the pyramid.
|
||||
|
||||
Stamps across the hole come from the active time source — exact in relative mode, the declared
|
||||
cadence continued from the last good stamp in stream mode, which is the bridge at `:274-292`.
|
||||
|
||||
Math channels take NaN and give NaN, so a math trace keeps its gap. The trigger engine's
|
||||
comparisons are false on NaN, so it cannot latch inside a hole; this is asserted by the probe
|
||||
rather than assumed.
|
||||
|
||||
**Reservation is capped at 256 missing packets**, reusing `kGlitchPackets` and its existing
|
||||
reasoning verbatim — *256 packet spans in one go is an outage, not loss*. Past the cap there is no
|
||||
reservation: log once and continue as today. That cap is also what stops a ten-minute outage from
|
||||
pushing billions of NaN samples through the ring and wiping all history to make room for a gap.
|
||||
|
||||
## Backfill
|
||||
|
||||
`plot::data::RingBuffer` and `MinMaxPyramid` are both strictly append-only today. Backfill needs
|
||||
an amend in each, and the two belong on different threads.
|
||||
|
||||
`plot_host.hpp:183` calls `acquire(ScopeModel&)` from the frame path, so the **pyramid is mutated
|
||||
only on the GTK main thread**, once per frame: the producer pushes into the ring and the main
|
||||
thread folds what was pushed into the pyramid. That split carries straight over to the amend.
|
||||
|
||||
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)`, bounds-checked against
|
||||
`[produced_ - capacity, produced_)`, a single relaxed store. The same race class as a push, and
|
||||
an `f32` cannot tear. Measurements and export read the ring directly, so they see a repair
|
||||
immediately.
|
||||
- **Pyramid amend — main thread, inside `acquire()`.** The producer appends amended `(slot, value)`
|
||||
pairs to a plain `std::vector<Amend>` member; `ChannelBuffer::acquire()` drains it **after** its
|
||||
append pass, so every amended index is already published. No SPSC queue is needed: `acquire()` is
|
||||
called under `model.mutex()` and the producer holds that same mutex for all of `on_data`, so the
|
||||
vector is correctly serialised without lock-free machinery.
|
||||
|
||||
The thread split is not a stylistic choice. A bucket is 32 bytes and is not atomic, so amending it
|
||||
from the producer would let a reader see a half-updated bucket whose `sum` does not match its
|
||||
`count` — a wrong mean from a structure whose readers trust `count == 0` to mean *absent*. On the
|
||||
main thread that cannot happen and no seqlock is needed.
|
||||
|
||||
`MinMaxPyramid::amend(index, v)` folds `v` into every covering bucket: at `count == 0`,
|
||||
`min = max = v`, `sum = v`, `sumsq = v²`, `count = 1`; otherwise widen `min`/`max` and accumulate
|
||||
`sum`/`sumsq`, `++count`. Its documented precondition is **non-finite → finite only**, which is
|
||||
what makes it purely additive and O(log n) with no rebuild. An index already dropped by
|
||||
`drop_before` is a no-op.
|
||||
|
||||
### The time ring is never amended
|
||||
|
||||
The hole was pre-stamped at reservation, so a backfill changes values only. `neighbours()` keeps
|
||||
the monotonic array its binary search requires and `window_x0_` stays valid. This is the reason
|
||||
reservation pre-stamps rather than leaving the ring short.
|
||||
|
||||
### Addressing a late packet
|
||||
|
||||
Reservation records `(counter_first, counter_last, tick_start, slot_start, slot_count)` in a
|
||||
bounded side table. A late frame's counter indexes straight into it:
|
||||
|
||||
```
|
||||
tick = tick_start + (counter - counter_first) * batch
|
||||
```
|
||||
|
||||
Only slots lying **wholly** inside the arrived packet's tick range are amended. A slot straddling
|
||||
a hole edge went NaN when its accumulator took a NaN, and its real members were never retained, so
|
||||
it stays NaN. A displayed hole is therefore at most one slot wider per edge than the true loss —
|
||||
invisible at any hole size worth repairing, and it makes a boundary slot need no special case.
|
||||
|
||||
The side table is bounded by the 256-packet reservation cap and a maximum entry count; it is also
|
||||
the source the shaded band reads.
|
||||
|
||||
## Surfacing
|
||||
|
||||
`StreamStats` gains `late_resequenced`, `late_committed` (gave up past 20 ms, or overflowed the
|
||||
reorder ring) and `late_backfilled`, and finally exposes `stale_packets` — which the client has
|
||||
counted since the beginning and which `src/status_text.cpp:70` has never shown, so an inversion
|
||||
today has to be inferred from `packets` and `frames` diverging. All four sit in the status bar's
|
||||
second tier.
|
||||
|
||||
The band is a new `Plot::set_invalid_spans()` in `deps/plotter`, drawn as a tinted quad behind the
|
||||
trace. Unlike the `t0` caption this *is* data-anchored — it must pan and zoom with the data it
|
||||
marks — so it is annotation-class, not chrome. The invariant it must respect is that it reserves
|
||||
no layout space and cannot be picked, so a hole cannot move the layout the labels were measured
|
||||
into.
|
||||
|
||||
## Verification
|
||||
|
||||
udpscope has no unit test suite; the only automated check is the application's own headless
|
||||
self-check (`CLAUDE.md`). This adds exit-dump lines and a `UDPSCOPE_PROBE_RESEQ_MS` probe,
|
||||
asserting: a reorder inside the window is resequenced with no hole; a reorder past the window
|
||||
leaves a hole and then backfills it; the trigger does not latch inside a hole; a 300-packet burst
|
||||
hits the cap and does not throw the stream onto the arrival clock; and relative mode's stamps are
|
||||
monotone with the declared spacing.
|
||||
|
||||
Runs are serial and short, against the two standing baselines: four build warnings on a clean
|
||||
build (two on an incremental build) and zero `Gtk-CRITICAL`. Batching several `xvfb` runs back to
|
||||
back starves the mock past its 2 s `silence_timeout_s` and reads as a reconnect, which is a
|
||||
measurement artefact and not a defect (`docs/claude/verification.md`).
|
||||
|
||||
**One gap in the mock has to be closed first.** `--reorder-every` releases the held packet
|
||||
immediately after the one that overtook it, so it is ~0 ms late and would always land inside the
|
||||
20 ms window. That exercises resequencing but leaves the **backfill path untestable**. The mock
|
||||
needs `--reorder-delay-ms` so a packet can be released deliberately past the window.
|
||||
|
||||
## Documentation this lands against
|
||||
|
||||
- `docs/claude/invariants-data-path.md` — append-only stops being the whole truth; the amend's
|
||||
precondition and its thread belong here.
|
||||
- `docs/claude/invariants-plot-and-layout.md` — the band, and the layout space it must not take.
|
||||
- `docs/claude/invariants-trigger-and-pinning.md` — the trigger does not latch inside a hole.
|
||||
- `docs/claude/verification.md` — the new probe and dump lines, and `--reorder-delay-ms`.
|
||||
- `docs/claude/build-and-run.md` — the vendored `udpsclient` diff: the reorder ring, the late
|
||||
delivery replacing the discard, and the third `clamp_deadline()`.
|
||||
- `README.md` and a `SPECS.md` deviation note for relative-time mode.
|
||||
|
||||
## Deliberately not in scope
|
||||
|
||||
- **Replaying math channels or the trigger over a backfilled region.** Decided against above.
|
||||
- **Interpolating across an unrepaired hole.** A hole is reported, not invented. There is no
|
||||
interpolation anywhere in the data path today and this does not add the first.
|
||||
- **Making relative mode the default.** It is a toggle. The default stays the declared time
|
||||
signal, so no existing session or run changes what it shows.
|
||||
- **The `(min, max, mean, rms)` gather mode** that would fix the separate solid-band degeneration
|
||||
at wide time windows. Related to the pyramid, unrelated to this; still open.
|
||||
@@ -0,0 +1,353 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# A square wave's riser is sometimes drawn and sometimes not, and the trace is thinner than the library's own default
|
||||
|
||||
The report is that a vertical line — the rising or falling edge of `square` in
|
||||
`tools/mock_udpstreamer.py` — is too thin to see, and that it *glitches in and out of
|
||||
existence*. Those are two complaints, not one, and they have different causes. The second is a
|
||||
defect with a derivable mechanism and a single line of code behind it. The first is a styling
|
||||
choice this app made and then never revisited: udpscope asks for a trace **thinner** than
|
||||
`deps/plotter`'s own default.
|
||||
|
||||
The three remedies named in `todo.md` — grade with more strength, thicken the lines, enlarge the
|
||||
sample dots — are all taken. They are not, on their own, enough: the glitch is a row being
|
||||
dropped *before* any of those knobs is consulted, so a thicker, brighter trace would glitch in
|
||||
and out at a thicker, brighter width.
|
||||
|
||||
## The defect: `lit_floor` decides existence, and `intensity_floor` only decides alpha
|
||||
|
||||
`Renderer::stage_trace_density()` (`deps/plotter/src/render/renderer.cpp`) accumulates one
|
||||
column's dwell into `column_rows_`, where **a segment deposits one unit spread over the rows it
|
||||
spans** — `w = 1 / (r1 - r0 + 1)`. It then decides which rows are inked at all:
|
||||
|
||||
```cpp
|
||||
const f32 lit_floor = 0.01f * peak;
|
||||
...
|
||||
const bool lit = r < rows && cover > lit_floor;
|
||||
```
|
||||
|
||||
`peak` is the column's own maximum cover. That makes the existence test a *share of the
|
||||
brightest row in the same column*, and for a square wave the brightest row in a column is a
|
||||
flat level, whose segments each deposit a full 1.0 into one row.
|
||||
|
||||
Take a column that holds both a flat level and the edge. At ~105 samples a column the gather is
|
||||
on raw samples, so the column holds ~105 segments: all but one are flat and deposit a full 1.0
|
||||
into one row, while the riser is a single segment spread over the whole axis and deposits
|
||||
`1/312 = 0.0032` into each of 312 rows. `peak` is then ~50 and `lit_floor` ~0.5 — **over a
|
||||
hundred times the riser's own deposit**. Every row of the riser is unlit. The connector at the
|
||||
bottom of `emit_column()` does not rescue it either: the top run and the bottom run are both lit,
|
||||
so the column's `band_lo…band_hi` already spans the full height, neither gap test fires, and no
|
||||
connector is staged. The column is drawn as a bright mark at the top and a bright mark at the
|
||||
bottom with nothing between them.
|
||||
|
||||
In the phase where the riser lands in a column *by itself*, `peak` is the riser's own deposit,
|
||||
the floor is a hundredth of it, every row clears it, and the riser is drawn full height. Which
|
||||
case a frame lands in depends on the phase of the bucket grid against the pixel grid, and the
|
||||
trace scrolls, so the phase sweeps. That is the whole of "glitches in and out of existence" — not
|
||||
a redraw race and not a data gap.
|
||||
|
||||
### Measured, before writing any of this down
|
||||
|
||||
A true square wave (period 2098 samples, so ~20 columns a period), 60 000 x-units over the 572 px
|
||||
axis of `deps/plotter`'s own headless EGL harness — 104.8 samples a column — drawn twice, once
|
||||
with `intensity_grading` off and once on, with the pixels read back both times over the **same**
|
||||
axes rect. The reading counts the columns the *ungraded* frame inked as a full-amplitude band
|
||||
(the riser columns) and then asks how many of exactly those rows the graded frame left dark:
|
||||
|
||||
| | riser columns | inked rows over them | intact | gutted | worst dark run | segments |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| ungraded | 116 | 35 884 | — | — | — | 627 |
|
||||
| graded, before | 116 | **2 916** | 10 | **106** | **311 of 312 rows** | 684 |
|
||||
| graded, after | 116 | 35 572 | 115 | 1 | — | 732 |
|
||||
|
||||
So the graded path was drawing **8 %** of the ungraded path's ink over the riser columns, and 106
|
||||
of 116 of them were gutted outright while 10 survived — the 10 being the phase that put a riser
|
||||
in a column of its own, which is the flicker, measured. The fix restores 99.1 % of the ink and
|
||||
costs 48 segments on a 572 px axis.
|
||||
|
||||
Two notes on that table, both of which the committed test must carry rather than smooth over:
|
||||
|
||||
- **The one column still gutted after the fix is column 11**, the second column of the window's
|
||||
first riser. The ungraded path inks *two* full-height columns per riser (its per-column min/max
|
||||
plus the connectivity extension, and the gathered window's slack sample) where the graded path
|
||||
inks one, which is also why 116 riser columns appear for ~58 risers. The graded path is the
|
||||
more precise of the two here; the test must assert against the riser's own column rather than
|
||||
against every column the flat path happened to fill.
|
||||
- **The existing grading test is unchanged by the fix** — `a_dense_trace_can_be_graded_by_dwell`
|
||||
still reads `flat edge=191 middle=191` against `graded edge=191 middle=131` at `seg=8303`, the
|
||||
documented figures to the digit, and all 376 cases pass. The fix only reaches rows that were
|
||||
below the share floor, which is what makes it a correction and not a restyling.
|
||||
|
||||
The app-level A/B this design originally proposed — `UDPSCOPE_NO_PHOSPHOR=1` against the mock's
|
||||
`square` — was dropped in favour of the above: udpscope has no probe that can see a dropped row,
|
||||
so the app-level run could only have shown that the picture differed, while the library harness
|
||||
reads the pixels and names the rows. The mechanism is measured at the layer it lives in.
|
||||
|
||||
`Style::intensity_floor`'s own documentation already promised what `lit_floor` broke:
|
||||
|
||||
> Not 0: a column the signal merely passed through is still a column the signal *reached*, and a
|
||||
> riser dimmed out of existence is a square wave drawn as two disconnected lines.
|
||||
|
||||
`intensity_floor` guards a lit row's **alpha**. Nothing guarded its **existence**, and the two
|
||||
floors were never reconciled.
|
||||
|
||||
### The fix, already applied and measured
|
||||
|
||||
`lit_floor` may not exceed the smallest deposit the column actually made, so that **a row one
|
||||
segment covers is always lit**. The column's minimum `w` is tracked as the deposits are written
|
||||
— one scalar, reset by `emit_column()`, no allocation, one `min` added to the per-segment path —
|
||||
and the floor becomes:
|
||||
|
||||
```cpp
|
||||
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
||||
```
|
||||
|
||||
The share term stays: it is what stops a row inking on the running-sum reconstruction's residue
|
||||
alone. The second term is what makes the test *about a measurement* rather than about a ratio to
|
||||
an unrelated row. The half is slack against that reconstruction, not a tuning knob.
|
||||
|
||||
This changes no geometry bound — the runs are still maximal stretches of equal tier, O(rows) per
|
||||
column at worst — and it touches neither the gather level, the pyramid, nor
|
||||
`stage_trace_columns()`. It is in the working tree (`deps/plotter/src/render/renderer.cpp`) with
|
||||
the figures above in the comment at its site, which is what Task 2 below is reduced to reviewing.
|
||||
|
||||
## The second defect, found by testing the first: grading was gated on the column reduction
|
||||
|
||||
The `lit_floor` fix was built and run against the real app, and the report back was that the
|
||||
edges are now always visible — and that a new thing is wrong: *"when I zoom in on an edge the
|
||||
brightness increases a lot compared to when I am looking at the whole waveform."* Clarified: the
|
||||
dimming itself is wanted, **a discontinuity should never be drawn as brightly as a flat top**, at
|
||||
any zoom. So the complaint is not about the amount of dimming but about its *inconsistency*.
|
||||
|
||||
Measured in the same harness, the riser's own ink at mid-height (the rows neither flat level can
|
||||
reach) against the flat level's 191, over a zoom sweep from 101 samples a pixel down to 0.01:
|
||||
|
||||
| samples/pixel | 101 | 33.7 | 10.1 | 3.4 | 1.01 | 0.34 | 0.10 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| riser ink, before | 71 | 71 | 71 | 71 | **191** | **191** | **191** |
|
||||
| riser ink, after | 71 | 71 | 71 | 71 | 71 | 71 | 68 |
|
||||
|
||||
A 2.7× jump in a single step, at `dense_threshold_spp = 1.5`. The cause is a coupling, not a
|
||||
constant: grading is staged *inside* `stage_trace_density()`, which only runs when the column
|
||||
reduction runs, and the reduction is gated on two gathered points per pixel column. That gate is
|
||||
a **density condition whose only purpose is removing the moiré beat** between the bucket pitch
|
||||
and the pixel pitch. Dwell has nothing to do with that beat, so zooming past the threshold
|
||||
switched the whole phosphor model off — and switched it off exactly where the user is looking at
|
||||
an edge closely, which is why it was reported as the edge *lighting up*.
|
||||
|
||||
### The fix: grade the polyline by each segment's slope
|
||||
|
||||
`Renderer::stage_trace_slopes()` stages the sparse polyline into the same `intensity_steps`
|
||||
batches, differing from the dense path only where it must:
|
||||
|
||||
- **The measure is `1 / (1 + |Δy_screen|)`** — dwell per row for a single segment. The `1 +` is
|
||||
not a division guard: a flat segment, span 0 and the commonest case in a sparse window, must be
|
||||
the *brightest* tier, and this makes the measure dimensionless and **absolute**. There is no
|
||||
per-column peak down here to normalise against, and that is an improvement — a sparse frame's
|
||||
tiers mean the same thing from frame to frame, so panning does not reshade the trace.
|
||||
- **No gather mode is excluded.** `grade` is not reused as the gate: it requires `Envelope`,
|
||||
because turning a bucket's min/max into a band answers a different question from the one a
|
||||
`Nearest` or `Mean` host asked. A segment's slope is a property of the polyline itself.
|
||||
- **The geometry is bit-for-bit `stage_xy()`'s**, same `snap = false`; only the batch differs. A
|
||||
segment with one non-finite end is dropped rather than clamped, which is the conclusion
|
||||
`add_polyline_xy()` reaches too.
|
||||
- **`FrameStats::slope_traces` is disjoint from `column_traces`**, so a host asserting
|
||||
`graded_traces == column_traces` keeps that reading unchanged.
|
||||
|
||||
Tier distribution sanity-checked by hand against the defaults (4 steps, gamma 0.45, floor 0.3):
|
||||
span 0 → tier 3, span 1 → 2, span 3 → 2, span 10 → 1, span 312 → 0. A gently sloping sparse sine
|
||||
therefore stays near the top tier and is not dimmed into invisibility, which was the risk.
|
||||
|
||||
### One false alarm, recorded because the metric was the bug
|
||||
|
||||
A first reading of this sweep showed the *frame's brightest pixel* falling from 191 to 167–172 on
|
||||
the sparse path whenever grading was on, which the arithmetic said was impossible (a flat segment
|
||||
is tier 3, alpha exactly 1.0). It was the measurement: the y range was −1…1, so both flat levels
|
||||
sat **on** the boundary rows and were half clipped, and the 191 the ungraded frame reported came
|
||||
from pixels near the riser where two segments overlap and double-composite — which grading splits
|
||||
across tiers, so they no longer saturate. With the range at −2…2 and the level read well clear of
|
||||
the riser, the level is 191 with grading on or off at every zoom. No regression existed. The
|
||||
committed test uses −2…2 and reads the level and the riser as two separate figures for this
|
||||
reason, stated at the site.
|
||||
|
||||
## The styling: the host asked for a thinner trace than the library's default
|
||||
|
||||
`RowSet::install()` (`src/plot_host.cpp:175`) builds every trace with `1.2f`. The library's
|
||||
`Trace::width` default is `1.5f`, and `Style::crosshair_width`'s documentation treats 1.5 as *the
|
||||
trace width a crosshair must read as thinner than*. So the app inverted its own stroke hierarchy
|
||||
at the only place it states it, and `1.2f` is also, in both staging paths, the **minimum span a
|
||||
short run is stroked at** — the `if (hi - lo < width)` widen — so it sets the floor on how
|
||||
visible a one-row run can ever be.
|
||||
|
||||
`marker_px` (3.0) and `marker_below_samples` (50) are library defaults udpscope never restated;
|
||||
`ui.cpp`'s one-shot style statement sets `intensity_grading` and nothing else.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- `deps/plotter`: the `lit_floor` correction in `stage_trace_density()`, a library test that fails
|
||||
without it, and the `PLOT_TEST_FILTER` the measurement needed.
|
||||
- `deps/plotter`: `Renderer::stage_trace_slopes()` — grading for the sparse polyline — plus
|
||||
`FrameStats::slope_traces` and a library test that fails without it.
|
||||
- `deps/plotter`: `Style::line_aa_px` and the grading constants remain *fields* — no new knob is
|
||||
added, and `intensity_grading` stays **one** switch covering both rasterisation paths. The one
|
||||
new field considered and rejected is named under **Out**.
|
||||
- udpscope: the trace width at `src/plot_host.cpp:175`, and the style one-shot in `ui.cpp`
|
||||
extended to state `marker_px`, `marker_below_samples`, `intensity_floor` and `line_aa_px`
|
||||
rather than inheriting them.
|
||||
- One new probe over the mock's `square`, and the `frame path:` reading extended to report the
|
||||
dropped-row case so a regression is visible rather than merely ugly.
|
||||
- `docs/claude/invariants-rendering.md`, `README.md`, and upstream's
|
||||
`docs/data-and-performance.md` + `README.md`.
|
||||
- `SPECS.md` is not changed.
|
||||
|
||||
**Out, and deliberately:**
|
||||
|
||||
- **A `Style` field for "always light a riser".** The dropped row is a bug in a test, not a
|
||||
policy a host should get to choose; a flag would preserve the broken behaviour as an option and
|
||||
oblige every host to know about it.
|
||||
- **Changing the connector's tier.** Drawing the inferred bridge at the faintest step is a
|
||||
deliberate honesty claim ("this is not a place the signal was measured to dwell"). Once
|
||||
`lit_floor` is fixed the riser is *real* ink at its own tier and the connector is back to
|
||||
bridging only genuine disjointness, which is what it was written for.
|
||||
- **Per-trace or per-row width.** One width for every trace in the app, as now.
|
||||
- **A separate switch for sparse grading.** Two switches would let a host reproduce the very
|
||||
inconsistency this fixes, and the user's own framing settles it: the dimming is wanted *at all
|
||||
zooms*, so it is one look and not two.
|
||||
- **Normalising the sparse tiers against the frame.** The dense path normalises per column
|
||||
because a frame-wide scale needs the whole image before anything can be drawn; the sparse
|
||||
measure needs no normalisation at all, and leaving it absolute is what makes a pan stop
|
||||
reshading the trace. Matching the dense path here would be matching a compromise.
|
||||
- **A user-facing preference for any of these values.** `todo.md` item 9 owns the preferences
|
||||
dialog; this work states defaults in code and leaves hooks for nothing.
|
||||
- No change to the gather level, `MinMaxPyramid`, `dense_threshold_spp`,
|
||||
`intensity_sub_columns`, the decimation plan, or the two-thread split.
|
||||
|
||||
## Global constraints
|
||||
|
||||
These bind every task below and are not restated in each one.
|
||||
|
||||
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||
- **`stage_trace_density()` stays allocation-free on the frame path.** `column_rows_` is already
|
||||
sized once per call and reused; the minimum-deposit tracking adds a scalar, not a container.
|
||||
- **No figure is written into a document unless it was measured on this machine.** The arithmetic
|
||||
above is derived and is labelled as derived; every number that reaches `README.md` or
|
||||
`docs/claude/` comes from a probe run quoted from that run.
|
||||
- **There is no unit test suite in udpscope.** Every assertion here is either a `deps/plotter`
|
||||
test (that project has one) or a probe line on stderr driven through the real widgets, read
|
||||
against the documented baselines: the documented build-warning count and zero `Gtk-CRITICAL`.
|
||||
- **Run one probe at a time.**
|
||||
- **The look is the user's call, the drop is not.** The `lit_floor` fix is correct or incorrect
|
||||
and a test says which. The four style values are judgments; they are stated in one place, with
|
||||
the before-value in a comment, so a second opinion is a one-line change.
|
||||
|
||||
## Task 0 — the test harness filter, already applied
|
||||
|
||||
`plottest::run_all()` ran all 376 cases unconditionally, so reading one case's output meant
|
||||
running the suite. `PLOT_TEST_FILTER` is a substring match honoured in `run_all()`
|
||||
(`deps/plotter/tests/harness.hpp`), unset meaning "all", which is what made the A/B above cheap
|
||||
enough to iterate on. Documented in upstream's `docs/building-and-testing.md`, including the trap
|
||||
that the summary line still counts the whole suite — so a filtered run says `377 cases, 0 failed`
|
||||
having run one, and is never evidence that the suite passes.
|
||||
|
||||
## Task 1 — the library test that fails now
|
||||
|
||||
Upstream, beside `a_dense_trace_can_be_graded_by_dwell`:
|
||||
`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`.
|
||||
|
||||
The scratch measurement above is the shape of it, and was deliberately **not** committed: it
|
||||
reports numbers rather than asserting, and it leans on the flat path as its reference, which the
|
||||
column-11 note shows is not exact. The committed test keeps the construction and replaces the
|
||||
reference.
|
||||
|
||||
The series and window are the measured ones — a true square of period 2098 over 60 000 x-units,
|
||||
~105 samples a column — because that density puts the gather on raw samples and makes a flat
|
||||
level's deposit a hundred times the riser's, which is the condition the defect needs. The riser
|
||||
must **share** its column, pinned by construction rather than by luck: it is the phase that
|
||||
survived in 10 of 116 columns, so a test that lets the phase float passes for the wrong reason
|
||||
about one time in twelve.
|
||||
|
||||
The assertion is *continuity over the riser's own column*, not a brightness profile — the
|
||||
existing grading test already profiles fifths and passes either way. Prefer the staged geometry
|
||||
over a pixel readback so the failure names the dropped rows rather than reporting that the
|
||||
picture changed, and have the failure print the gap's row range.
|
||||
|
||||
It must fail against the pre-fix `lit_floor`; verify that by reverting the one line, not by
|
||||
trusting the figures in this document.
|
||||
|
||||
**Done.** `a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`, beside the dwell test.
|
||||
It chooses its riser columns by the property at stake — a band spanning more than half the axis,
|
||||
which on a square wave is a transition and nothing else — from the ungraded frame, then re-reads
|
||||
exactly those columns from the graded one and asserts 95 % of them are still tall. Reading
|
||||
`kept=115` of `flat=116`, and `kept=10` with `lit_floor` reverted to `0.01f * peak`, which is the
|
||||
required failure verified rather than assumed. The column-11 slack is in the assertion's 95 % and
|
||||
its reason is in the comment.
|
||||
|
||||
## Task 1b — the library test for slope grading
|
||||
|
||||
`a_sparse_trace_is_graded_by_slope_like_a_dense_one`: one window either side of
|
||||
`dense_threshold_spp` over the same transition at the same place, asserting that the two frames
|
||||
took the two paths (`graded_traces` against `slope_traces`), that **neither dims the level**
|
||||
(191 either way), that **both dim the riser** to under 55 % of it, and that the two riser
|
||||
readings agree to within one tier. Reading 71/191 against 71/191; with the slope staging gated
|
||||
off the sparse frame reads 191/191 and the path assertion fails first.
|
||||
|
||||
## Task 2 — review the fix
|
||||
|
||||
The change is in the tree. What is left is to read the comment at its site against the measured
|
||||
numbers it quotes, and to confirm `Style::intensity_floor`'s doc comment now describes reality,
|
||||
since it is cited above as having promised what the code did not deliver.
|
||||
|
||||
## Task 3 — the four stated values
|
||||
|
||||
`src/plot_host.cpp:175` from `1.2f` to at least the library's `1.5f`. The comment records that
|
||||
the app had been asking for less than the default and that this width is also the minimum span a
|
||||
short run is stroked at.
|
||||
|
||||
`ui.cpp`'s style one-shot (`src/ui.cpp:6149`) states, each with the inherited value in a comment:
|
||||
|
||||
- `marker_px` — `todo.md` 1.3, "the sample points should be bigger". The dots only appear below
|
||||
`marker_below_samples` raw samples in the window, so this is the deep-zoom reading and is
|
||||
independent of everything above.
|
||||
- `marker_below_samples` — raising it brings the dots back at windows that currently show a bare
|
||||
polyline.
|
||||
- `intensity_floor` and `line_aa_px` — the two readings of `todo.md` 1.1, "the antialiasing
|
||||
strategy with phosphore effect should be applied with more stregth", and they are different
|
||||
things: `intensity_floor` lifts the *faintest graded tier*, which is what a riser is drawn at
|
||||
even once it is lit; `line_aa_px` widens the anti-aliased fringe on **every** stroke in the
|
||||
library, which is literally more anti-aliasing and makes a thin line read fatter at the cost of
|
||||
a softer grid and axis. Both are stated; the values are the ones to look at on screen and
|
||||
adjust, and the task's report says what each one did on its own.
|
||||
|
||||
Nothing here is conditional on Task 2, and nothing in Task 2 is conditional on these.
|
||||
|
||||
## Task 4 — the probe
|
||||
|
||||
`UDPSCOPE_PROBE_EDGEINK_MS`: plot the mock's `square`, settle on a window dense enough for the
|
||||
graded path, and report — per the line reference in `docs/claude/probe-readings.md` — the edge
|
||||
column's staged span against the band it should cover, and the count of unlit rows inside it.
|
||||
**It must read 0 dropped rows**, and it must be run with and without `UDPSCOPE_NO_PHOSPHOR=1`:
|
||||
the ungraded path is the control, and the two must agree about where the trace is.
|
||||
|
||||
`frame path:` gains nothing new if the probe above covers the drop; the brief for this task
|
||||
decides that from the probe's output, and whichever way it goes,
|
||||
`docs/claude/probe-readings.md` gets the per-line entry.
|
||||
|
||||
## Task 5 — the documents
|
||||
|
||||
`docs/claude/invariants-rendering.md` already carries the grading invariant and the sentence
|
||||
"the floor so that a fast riser, one sub-bucket deep everywhere, is never dimmed out of
|
||||
existence". That is the claim this defect falsified, so the bullet gains the distinction the code
|
||||
was missing — **an alpha floor is not an existence floor** — and names the symptom, the phase
|
||||
dependence, and `UDPSCOPE_NO_PHOSPHOR=1` as the A/B that separates the two.
|
||||
|
||||
`README.md` records the stroke and marker values as deliberate deviations from the library's
|
||||
defaults, under the existing deviations section.
|
||||
|
||||
Upstream `docs/data-and-performance.md` and `README.md` carry the `lit_floor` rule and the slope
|
||||
path, since both are the library's contract and not this app's. **Done**, together with
|
||||
`Style::intensity_grading`'s own doc comment (which described the dense path as the whole of the
|
||||
feature), `docs/building-and-testing.md`'s `PLOT_TEST_FILTER` entry, and the case counts in all
|
||||
three places that quote them — 350 → 377, and 333 → 360 for the coverage preset.
|
||||
@@ -0,0 +1,328 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# UDP packet capture: design
|
||||
|
||||
`todo.md` item 4: *optional udp frame storage for debugging of strange behaviours (store last
|
||||
`<N>`, default = 1000, udp packets binary content on disk for forensic analysis).*
|
||||
|
||||
The strange behaviours worth forensics on are all *wire* behaviours — a counter that skips, a
|
||||
datagram that arrives twice, a fragment that never completes, a CONFIG whose header does not parse,
|
||||
a burst that outruns the reorder ring. Every one of them is invisible by the time a
|
||||
`udps_frame_t` reaches `Acquisition::on_data`: the client has already validated the magic,
|
||||
reassembled the fragments, resequenced what it could and dropped what it could not. A capture that
|
||||
is to answer *what did the wire actually send* must therefore sit at `recv()`, and must store the
|
||||
bytes without interpreting them.
|
||||
|
||||
This is a debugging aid, not a feature of the instrument. It is off by default, it adds nothing to
|
||||
the glance tier of the status bar, and it must not be able to cost a single packet when it is on.
|
||||
|
||||
## The decisions taken, and what each one rules out
|
||||
|
||||
| Decision | Alternative rejected |
|
||||
| --- | --- |
|
||||
| Always-on rolling ring while enabled; the last N are on disk at all times | Keeping the ring in RAM and flushing on an anomaly or on demand — a crash or a `SIGKILL` then leaves nothing, which is the case a forensic capture exists for |
|
||||
| A fixed-size file of N slots, plus a converter to pcap in `tools/` | Writing pcap directly (grows, so "last N" needs two rotating files and the newest N is split across both) or one file per datagram (1000 files, an open/write/close per packet in the receive path) |
|
||||
| `mmap` once, `memcpy` per datagram | A `pwrite` per datagram (a syscall, and on a busy disk a blocking one, between two `recv()` calls) or a third thread with a queue (a second copy of every datagram, and this application's two-thread property is load-bearing) |
|
||||
| One path per source, previous capture kept one deep | A timestamped file per enable (needs the retention policy `todo.md` item 9 defines and this does not build) or a single path overwritten (re-enabling destroys the capture you enabled for) |
|
||||
| `--capture-packets[=N]` as well as the popover button | Button only — by the time the window is up and clickable, the first connect and its CONFIG are already history, and those are exactly the "strange behaviours" hardest to reproduce |
|
||||
|
||||
## Architecture
|
||||
|
||||
Three parts, each with one responsibility and a seam that does not leak the other two.
|
||||
|
||||
### 1. The client hands over raw datagrams
|
||||
|
||||
`deps/udpsclient` gains **one additive entry point**:
|
||||
|
||||
```c
|
||||
/** 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);
|
||||
|
||||
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||
udps_datagram_cb cb, void *user);
|
||||
```
|
||||
|
||||
A separate setter, **not** a fourth parameter on `udps_client_set_callbacks`
|
||||
(`udps_client.h:283`): that signature has call sites, and a capture hook is no reason to change
|
||||
them.
|
||||
|
||||
The call site is `udps_client.c:1458`, immediately after
|
||||
|
||||
```c
|
||||
ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0);
|
||||
```
|
||||
|
||||
returns a positive `n`, and before anything is read out of `rxbuf`. `recv_time` is the same clock
|
||||
the client already stamps arrivals with, so a capture's times and the stream statistics agree.
|
||||
|
||||
**Boundary, stated rather than discovered later:** in multicast mode CONFIG arrives over TCP
|
||||
(`udps_client.c:1142`) and this hook does not see it. The capture is of the UDP socket. A malformed
|
||||
*multicast* CONFIG is therefore out of reach, and `README.md` will say so.
|
||||
|
||||
### 2. `PacketCapture` owns the file
|
||||
|
||||
New unit, `src/capture.hpp` / `src/capture.cpp`. One class, one responsibility — own the ring file
|
||||
and store a datagram into it. It includes no GTK, no `ScopeModel`, and no client types beyond
|
||||
`<cstdint>`; its whole interface is:
|
||||
|
||||
```cpp
|
||||
class PacketCapture {
|
||||
public:
|
||||
/// Renames an existing capture to `capture-prev.bin`, creates and reserves the new file,
|
||||
/// maps it. Returns false and leaves the object disabled on any failure, with `error()` set.
|
||||
bool open(const std::string& path, std::uint32_t slot_count);
|
||||
void close(); ///< unmap, close; idempotent
|
||||
bool enabled() const noexcept;
|
||||
|
||||
/// One datagram. Called only from the producer thread. Never allocates, never blocks,
|
||||
/// never fails: an over-long datagram is truncated to the slot and counted.
|
||||
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||
|
||||
std::uint64_t stored() const noexcept; ///< datagrams since open()
|
||||
std::uint64_t truncated() const noexcept;
|
||||
std::uint32_t slot_count() const noexcept;
|
||||
const std::string& path() const noexcept;
|
||||
/// Copies the last open() failure out, or writes an empty string. Not a
|
||||
/// `const std::string&`: see "What crosses between the threads" below.
|
||||
void error(char* out, std::size_t cap) const noexcept;
|
||||
};
|
||||
```
|
||||
|
||||
#### What crosses between the threads
|
||||
|
||||
The producer thread writes this state and the GTK thread reads it every frame for the popover, so
|
||||
each field has to be publishable without a lock:
|
||||
|
||||
| Field | Type | Why it is safe |
|
||||
| --- | --- | --- |
|
||||
| `enabled_`, `stored_`, `truncated_` | `std::atomic<…>`, relaxed | counters and a flag, not a consistent set — the same treatment `StreamStats` already gets |
|
||||
| `path_` | `std::string` | written **once** by the GTK thread before it ever sets `capture_want_`, and only read afterwards |
|
||||
| the open failure | `char error_[128]` plus `std::atomic<bool> error_set_` | the producer fills the buffer and *then* sets the flag with a release store; the GTK thread copies the buffer only when the flag reads true |
|
||||
|
||||
The error is deliberately **not** a `std::string` accessor. A `std::string` written by the producer
|
||||
inside `open()` and read by the GTK thread on the next frame is a data race with a reallocating
|
||||
buffer — the kind that reads correctly for months. The fixed buffer with a flag published after it
|
||||
is the same release-store trick the slot's `seq` field uses, for the same reason.
|
||||
|
||||
#### The file
|
||||
|
||||
```
|
||||
offset 0, 64 bytes — header
|
||||
magic u8[8] "UDPSCAP\0"
|
||||
version u32 1
|
||||
slot_count u32 N
|
||||
slot_bytes u32 sizeof(record header) + UDPS_RX_BUF_BYTES, 8-byte aligned
|
||||
cursor u32 next slot index to write (a hint; see below)
|
||||
t0_realtime f64 CLOCK_REALTIME at open(), the epoch recv_time is relative to
|
||||
peer_addr u32 the source's IPv4, network order
|
||||
peer_port u16
|
||||
local_port u16
|
||||
reserved to 64 bytes, zeroed
|
||||
|
||||
offset 64 + i * slot_bytes, for i in [0, N) — slot i
|
||||
t_arrival f64 the client's recv_time
|
||||
len u32 bytes stored (after truncation, if any)
|
||||
orig_len u32 bytes the wire delivered, so a truncation is visible
|
||||
bytes u8[len]
|
||||
seq u64 1-based ordinal since open(); written LAST
|
||||
```
|
||||
|
||||
`seq` is written last, after the bytes, with a release store. Three things follow, and they are the
|
||||
reason the format is this and not simpler:
|
||||
|
||||
- **A slot carrying a non-zero `seq` is complete.** 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 has
|
||||
`seq == 0`.
|
||||
- **`seq`, not `cursor`, is the order.** After the ring wraps, the newest slot is the one with the
|
||||
largest `seq`, and the reader needs no trust in a header field that is updated out of band.
|
||||
- **A torn write is detectable, not silent.** The reader reports the count of empty and torn slots
|
||||
rather than averaging over them.
|
||||
|
||||
`cursor` is maintained anyway, because a human running `xxd` on the file wants to know where to
|
||||
look.
|
||||
|
||||
#### Why the file is reserved, not sparse
|
||||
|
||||
`posix_fallocate` reserves the whole file at `open()`. The obvious alternative — `ftruncate` and
|
||||
let the file stay sparse, so it costs only the bytes actually written — is wrong with `mmap`: a
|
||||
store into a page the filesystem cannot back raises **`SIGBUS`**, which kills udpscope outright.
|
||||
A capture must not be able to take down the instrument it is observing, and an out-of-space
|
||||
condition must be an error at the moment the user clicks, not a crash ten minutes later.
|
||||
|
||||
At the default `N = 1000` and `UDPS_RX_BUF_BYTES = 65552`, the file is **~62.6 MiB**. That is
|
||||
stated in the help text, in the tooltip and in the popover row, because it is the one cost of
|
||||
switching the feature on.
|
||||
|
||||
### 3. `Acquisition` drives it, and the GTK thread only ever sets an atomic
|
||||
|
||||
`Acquisition` owns the `PacketCapture`, installs the datagram callback next to the three it already
|
||||
installs (`src/acquisition.cpp:62`), and does **every** file operation on the producer thread.
|
||||
|
||||
```
|
||||
GTK thread (the button) producer thread (the datagram callback)
|
||||
--------------------------- --------------------------------------------
|
||||
capture_want_.store(true) ──► reads capture_want_ at the top of each datagram
|
||||
capture_path_ written once rising edge → open() : rename, create, fallocate, mmap
|
||||
before the first store falling edge → close(): munmap, close
|
||||
then, if enabled, store(bytes, len, recv_time)
|
||||
```
|
||||
|
||||
No mutex, no queue, no second copy of any datagram, and the application still has exactly two
|
||||
threads.
|
||||
|
||||
The one-off cost of a toggle is the `open()`: a rename, a create, a `posix_fallocate` of 62.6 MiB
|
||||
and an `mmap`, on the producer thread. It is a user-initiated act that happens once per toggle, and
|
||||
`README.md` will say that it can cost the packets in flight during it. Moving it off the producer
|
||||
thread would need the lock or the thread this design exists to avoid, for an event that happens
|
||||
when a human clicks a button.
|
||||
|
||||
The duration is **measured, not asserted**: `open()` records its own wall time and the probe prints
|
||||
it as `open_ms`. `posix_fallocate` on a local extent-based filesystem is a metadata operation and
|
||||
should be a handful of milliseconds, but that is a claim about the filesystem, not about this code,
|
||||
and a reading above ~50 ms is a finding worth putting in `README.md` rather than a number to
|
||||
predict here.
|
||||
|
||||
### Error handling, and only at this boundary
|
||||
|
||||
This is a syscall boundary, so it is validated; nothing else here is.
|
||||
|
||||
| Condition | Behaviour |
|
||||
| --- | --- |
|
||||
| `open`, `posix_fallocate` or `mmap` fails (ENOSPC, EACCES, a read-only cache directory) | capture stays off, `error()` carries `strerror`, one line on stderr, the toggle button returns to off and the popover row shows the reason |
|
||||
| the cache directory does not exist | created with `g_mkdir_with_parents`, 0700; a failure there is the row above |
|
||||
| `len > UDPS_RX_BUF_BYTES` | impossible — the slot is sized to the client's own receive buffer — but `store()` truncates to the slot, records `orig_len`, and counts it in `truncated()` rather than trusting the impossibility |
|
||||
| the previous `capture-prev.bin` cannot be replaced | the capture still opens; the rename failure is reported, the old previous is kept |
|
||||
| capture enabled with no connection yet | the button is insensitive until connected, because the path is keyed on the source; `--capture-packets` defers `open()` to the first datagram, which is what arms it before the first CONFIG |
|
||||
|
||||
## Surfaces
|
||||
|
||||
### The popover button and row
|
||||
|
||||
The status bar's detail popover (`src/ui.cpp:7619-7672`) is a two-column grid: four bold headings
|
||||
at rows 0, 3, 5, 6 and seven value labels at rows 0..6, in `app->popover_label[11]`. It gains a
|
||||
fifth heading, **Capture**, and an eighth value label; the array becomes `[13]` and the
|
||||
`kHeadings` / `kHeadingRow` tables gain their entry. `update_status_popover`
|
||||
(`src/ui.cpp:6101`) writes the new label like the others — `gtk_label_set_text` only, no rebuild.
|
||||
|
||||
The row reads one of:
|
||||
|
||||
```
|
||||
off
|
||||
on · 412/1000 slots · 26.1 MiB · ~/.cache/udpscope/192.168.1.40-5000/capture.bin
|
||||
failed: No space left on device
|
||||
```
|
||||
|
||||
Below the grid, a `GtkToggleButton` labelled **Capture packets** with the
|
||||
`media-record-symbolic` icon and a tooltip naming the full path and the file's size. `toggled`
|
||||
sets the atomic and nothing else. Insensitive until connected.
|
||||
|
||||
Nothing is added to `format_detail()`'s note string: `src/status_text.cpp:86` is a `char
|
||||
note[160]` already close to its worst case, and the capture state is not something the exit dump's
|
||||
one line needs.
|
||||
|
||||
### The command line
|
||||
|
||||
```
|
||||
--capture-packets N store the last N raw datagrams on disk for forensics, in a
|
||||
ring under the cache directory (1000 slots is ~62.6 MiB);
|
||||
off unless given, and toggleable from the status bar
|
||||
```
|
||||
|
||||
Parsed in `src/config.cpp`'s existing style — both `--capture-packets N` and
|
||||
`--capture-packets=N`, `N` in 1..100000 — and the value is **required**, not optional.
|
||||
`--capture-packets` with an optional value cannot be told apart from `--capture-packets` followed
|
||||
by a value in this parser, and every other option in it takes the space-separated form, so a bare
|
||||
flag would turn `--capture-packets 500` into `unknown argument: 500`. The 1000 default is the
|
||||
slot count the **popover button** uses when the flag was not given; with the flag, the button uses
|
||||
the N the flag named.
|
||||
|
||||
`Config::capture_packets` is a `std::uint32_t`: 0 means the flag was absent, and the button then
|
||||
uses 1000.
|
||||
|
||||
The flag arms the ring before the first datagram; it is not persisted in the session file. That
|
||||
persistence belongs with `todo.md` item 3's work, alongside the relative-time toggle deferred to
|
||||
the same place.
|
||||
|
||||
### `tools/capture_to_pcap.py`
|
||||
|
||||
```
|
||||
$ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap
|
||||
1000 slots, 412 stored, 0 torn, 0 truncated → 412 packets, 26.1 MiB
|
||||
$ wireshark out.pcap
|
||||
```
|
||||
|
||||
Reads the header, validates the magic and version, walks the slots, orders by `seq`, skips
|
||||
`seq == 0`, and emits a classic pcap (`LINKTYPE_ETHERNET`, microsecond timestamps from
|
||||
`t0_realtime + t_arrival`) with a synthesised Ethernet/IPv4/UDP header per record built from the
|
||||
header's `peer_addr`, `peer_port` and `local_port`. Pure standard library — the mock already
|
||||
forces a hard `numpy` dependency on `tools/`, and this script has no reason to add to that.
|
||||
|
||||
It also prints the counts above to stderr, which is what makes it a diagnostic in its own right:
|
||||
`torn` or `truncated` above zero is a finding about the capture, before anything about the stream.
|
||||
|
||||
## Verification
|
||||
|
||||
udpscope has no unit test suite; the capture is verified by the application's own headless
|
||||
self-check, and the converter by running it.
|
||||
|
||||
`UDPSCOPE_PROBE_CAPTURE_MS` toggles capture **through the same handler the button uses**, waits a
|
||||
scheduled interval, then reads the file back off disk and prints one line:
|
||||
|
||||
```
|
||||
[capture-probe] enabled=1 path=… slots=1000 stored=412 cursor=412 torn=0 truncated=0
|
||||
header_ok=1 first_seq=1 last_seq=412 payload=1648000 file=65576064
|
||||
open_ms=3.1 t0=1760000000.123
|
||||
```
|
||||
|
||||
`payload` is the sum of every stored slot's `len` — the bytes the wire actually delivered, which is
|
||||
what tells you whether the ring is holding what you think. `file` is the reserved size,
|
||||
`64 + slot_count * slot_bytes`, and is a constant for a given N: it is in the line so that a
|
||||
mismatch against the file on disk is a reading rather than something to go and check.
|
||||
|
||||
What each field has to read, and the defect a wrong reading names, goes in
|
||||
`docs/claude/probe-readings.md` beside the other probes. Two cases in
|
||||
`docs/claude/verification.md`:
|
||||
|
||||
1. **enable at runtime** — the probe toggles on mid-run; `stored` must equal the datagrams the
|
||||
exit dump counts from that instant, `torn=0`, and `first_seq=1`.
|
||||
2. **wrap** — `--capture-packets=16` against the standard mock, run long enough to wrap several
|
||||
times; `stored` exceeds `slot_count`, `last_seq - first_seq == 15`, `torn=0`. A wrap that loses
|
||||
ordering, or a `cursor` that disagrees with `seq`, shows here and nowhere else.
|
||||
|
||||
Then the converter, run over the wrap case's file: exit 0, a record count equal to `slot_count`,
|
||||
and the pcap's own first and last record timestamps inside the run's wall-clock interval. The
|
||||
check is the converter's printed counts plus a `struct.unpack` of the pcap's first and last record
|
||||
headers — no Wireshark, no `capinfos`, nothing outside the standard library, so it runs wherever
|
||||
the self-check runs.
|
||||
|
||||
The standing baselines apply unchanged: a clean build at exactly four warnings, an incremental at
|
||||
two, and zero `Gtk-CRITICAL` in any headless run.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Replaying a capture back into udpscope.** That is `todo.md` item 5's territory (opening stored
|
||||
waveforms) and a different feature with a different shape.
|
||||
- **Retention beyond one deep.** `capture.bin` and `capture-prev.bin`, nothing more, until item 9
|
||||
defines the cache-size preferences.
|
||||
- **Session persistence of the enable state.** Deferred to item 3 with the relative-time toggle,
|
||||
so one piece of work adds the session fields rather than two.
|
||||
- **Capturing the multicast TCP CONFIG channel.** Stated as a limitation in `README.md`.
|
||||
- **Compression, encryption or rotation of the ring.** A fixed file of raw bytes is what a
|
||||
forensic reader wants, and `gzip` exists.
|
||||
|
||||
## Documentation to update
|
||||
|
||||
- `README.md` — the feature, the 62.6 MiB cost, the producer stall on toggle, the multicast CONFIG
|
||||
limitation, the converter's usage.
|
||||
- `SPECS.md` — nothing: this is a `todo.md` item, not a spec requirement, and not a deviation.
|
||||
- `todo.md` — item 4 struck, on one line, with the flag's name (GFM strikethrough is inline and
|
||||
cannot span list items).
|
||||
- `docs/claude/build-and-run.md` — the new option; `verification.md` and `probe-readings.md` — the
|
||||
probe and its two cases; `docs/claude/invariants-data-path.md` — one paragraph on why the hook is
|
||||
at `recv()` and why the file is reserved rather than sparse.
|
||||
+500
-52
@@ -5,6 +5,13 @@
|
||||
|
||||
#include <udps_client.h>
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
#include <glib/gstdio.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
@@ -14,6 +21,12 @@
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// How far a packet's stamps may run ahead before the jump is read as a clock fault rather
|
||||
/// than a stream gap. Also the cap for hole reservation: 256 packet spans in one go is an
|
||||
/// outage, not a loss, and reserving one would push the entire retained history out of the
|
||||
/// ring to make room for samples that are not coming.
|
||||
constexpr double kGlitchPackets = 256.0;
|
||||
|
||||
SignalInfo to_info(const udps_signal_t& s) {
|
||||
SignalInfo info;
|
||||
info.name = s.name;
|
||||
@@ -55,6 +68,13 @@ void Acquisition::start() {
|
||||
}
|
||||
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
|
||||
&Acquisition::event_trampoline, this);
|
||||
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
|
||||
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
|
||||
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
|
||||
// The open itself still happens on the producer thread, at the first datagram.
|
||||
if (cfg_.capture_packets > 0) {
|
||||
set_capture_want(true, cfg_.capture_packets);
|
||||
}
|
||||
|
||||
started_ = std::chrono::steady_clock::now();
|
||||
running_.store(true, std::memory_order_release);
|
||||
@@ -85,11 +105,69 @@ void Acquisition::run() {
|
||||
out.packets.store(s.packets_received, std::memory_order_relaxed);
|
||||
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
|
||||
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
|
||||
out.resequenced.store(s.late_resequenced, std::memory_order_relaxed);
|
||||
out.late.store(s.late_delivered, std::memory_order_relaxed);
|
||||
out.stale.store(s.stale_packets, std::memory_order_relaxed);
|
||||
out.overflows.store(s.reorder_overflows, std::memory_order_relaxed);
|
||||
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
|
||||
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
|
||||
if (want) {
|
||||
if (slots > 0) capture_slots_ = slots;
|
||||
if (capture_path_.empty()) {
|
||||
// Keyed on the source: one path per source, the previous capture kept one deep.
|
||||
std::string host = cfg_.server;
|
||||
for (char& ch : host) {
|
||||
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
|
||||
}
|
||||
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
|
||||
g_mkdir_with_parents(dir.c_str(), 0700);
|
||||
capture_path_ = dir + "/capture.bin";
|
||||
std::uint32_t addr = 0;
|
||||
struct ::in_addr a;
|
||||
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
|
||||
addr = a.s_addr; // already network order, which is what the header wants
|
||||
}
|
||||
capture_.set_peer(addr, cfg_.port);
|
||||
}
|
||||
}
|
||||
capture_want_.store(want, std::memory_order_release);
|
||||
}
|
||||
|
||||
void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time) {
|
||||
const bool want = capture_want_.load(std::memory_order_acquire);
|
||||
if (want && !capture_.enabled()) {
|
||||
// A user-initiated act that happens once per toggle, on this thread: a rename, a
|
||||
// create, a 62.6 MiB reserve and an mmap. It can cost the packets in flight, and
|
||||
// the measured cost is `capture_.open_ms()`.
|
||||
if (!capture_.open(capture_path_, capture_slots_)) {
|
||||
char err[128];
|
||||
capture_.error(err, sizeof(err));
|
||||
std::fprintf(stderr, "[udpscope] packet capture failed to open: %s\n", err);
|
||||
// Stay off rather than retrying every datagram against a full disk; the GTK
|
||||
// thread reads the flag back and returns the button to off.
|
||||
capture_want_.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
std::fprintf(stderr, "[udpscope] packet capture on: %s (%u slots, %.1f ms to open)\n",
|
||||
capture_.path().c_str(), capture_.slot_count(), capture_.open_ms());
|
||||
} else if (!want && capture_.enabled()) {
|
||||
capture_.close();
|
||||
std::fprintf(stderr, "[udpscope] packet capture off\n");
|
||||
}
|
||||
if (capture_.enabled()) {
|
||||
capture_.store(bytes, len, recv_time);
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||
double recv_time, void* user) {
|
||||
static_cast<Acquisition*>(user)->on_datagram(bytes, len, recv_time);
|
||||
}
|
||||
|
||||
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode) {
|
||||
std::vector<SignalInfo> info;
|
||||
@@ -112,6 +190,18 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
|
||||
sig.time_signal_idx, sig.unit.c_str());
|
||||
}
|
||||
std::fflush(stderr);
|
||||
// The warn-once latches are per *connection*, not per process. A reconnect re-enters here, and
|
||||
// a diagnostic that goes quiet after the first occurrence of a condition that recurs on the
|
||||
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
|
||||
warned_batch_ = false;
|
||||
warned_outage_ = false;
|
||||
warned_bridge_ = false;
|
||||
warned_time_source_ = false;
|
||||
// The clock choice is per connection too: a reconnect re-derives it from the frames
|
||||
// the new session actually delivers, rather than trusting a decision made about a
|
||||
// session that dropped.
|
||||
time_source_ = 0;
|
||||
reset_session_state();
|
||||
model_.stats().have_config.store(true, std::memory_order_relaxed);
|
||||
model_.set_signals(std::move(info));
|
||||
}
|
||||
@@ -122,9 +212,31 @@ void Acquisition::on_event(int event, const char* detail) {
|
||||
model_.stats().connected.store(true, std::memory_order_relaxed);
|
||||
} else if (event == UDPS_EVENT_DISCONNECTED) {
|
||||
model_.stats().connected.store(false, std::memory_order_relaxed);
|
||||
// The session dropped mid-flight: everything the producer knows about *that*
|
||||
// session's numbering and clocks is now a lie about the next one. Clearing it here
|
||||
// is what makes the next frame re-anchor instead of reading as late, as a glitch or
|
||||
// as an outage -- and nothing here touches the buffer, whose history survives the
|
||||
// drop (see `ScopeModel::set_signals`'s unchanged-CONFIG path).
|
||||
reset_session_state();
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::reset_session_state() noexcept {
|
||||
have_counter_ = false;
|
||||
last_counter_ = 0;
|
||||
have_stream_anchor_ = false;
|
||||
last_stream_anchor_ = 0.0;
|
||||
stamp_failures_ = 0;
|
||||
// The arrival clock's *anchor* stays -- stamps are small seconds the ring's timeline
|
||||
// is already scaled against, and re-anchoring mid-stream would move x -- but the
|
||||
// smoothed interval and the previous arrival are per-connection measurements, and
|
||||
// carrying them across a multi-second outage makes the first new frame read as a
|
||||
// clock fault rather than as the resumption it is.
|
||||
have_arrival_interval_ = false;
|
||||
arrival_interval_ = 0.0;
|
||||
arrival_prev_ = 0.0;
|
||||
}
|
||||
|
||||
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
|
||||
// The frame's span in base ticks: a scalar signal carries `num_samples` values
|
||||
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
|
||||
@@ -135,6 +247,71 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
|
||||
}
|
||||
stamps_.assign(n, 0.0);
|
||||
abs_stamps_.assign(n, 0.0);
|
||||
|
||||
// Samples per packet, needed in both modes: relative stamping must advance `slot_base_`
|
||||
// past the reserved hole before placing this frame's own stamps, and `on_data` must size
|
||||
// the hole before calling this function -- both operations require knowing how many samples
|
||||
// a missing packet represents, which is only knowable from the first frame that arrives.
|
||||
if (batch_ == 0) {
|
||||
batch_ = n;
|
||||
if (cfg_.relative_time) {
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] relative time: batch=%u samples/packet, rate=%.6g S/s declared\n",
|
||||
batch_, model_.partition().base_dt > 0.0 ? 1.0 / model_.partition().base_dt
|
||||
: 0.0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
} else if (n != batch_) {
|
||||
// A frame whose length disagrees with the first one is an inconsistency in the
|
||||
// producer, not a loss: the counter still names one packet. Warn once and keep the
|
||||
// established batch, which is only used to size holes -- the timeline itself is built
|
||||
// from the lengths actually seen, so it stays exact for this frame either way.
|
||||
if (!warned_batch_) {
|
||||
warned_batch_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: packet carries %u samples but the stream's first "
|
||||
"carried %u; keeping %u as the packet length a lost packet is worth.\n",
|
||||
n, batch_, batch_);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing
|
||||
// below this block runs -- not the arrival-cadence override, not the degeneracy guard, not
|
||||
// the glitch bridge, not the monotonic clamp's reason for existing -- because a position
|
||||
// cannot jitter, cannot fail to advance and cannot jump.
|
||||
if (cfg_.relative_time) {
|
||||
double dt = model_.partition().base_dt;
|
||||
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||
// The reserved hole for the missing packets advances `slot_base_` so this frame's
|
||||
// own stamps land *after* the hole rather than on top of it. The same conditions
|
||||
// the reservation call uses: not late, gap present, batch known, within the cap.
|
||||
if (!frame->late && frame->lost > 0 && batch_ > 0 &&
|
||||
double(frame->lost) <= kGlitchPackets) {
|
||||
slot_base_ += u64(frame->lost) * batch_;
|
||||
}
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
stamps_[k] = double(slot_base_ + k) * dt;
|
||||
}
|
||||
slot_base_ += n;
|
||||
// The absolute clock is still read, for export only. When the stream carries no usable
|
||||
// time signal there is no absolute clock to record and the relative stamp is the honest
|
||||
// answer -- an export then says what the counter says, which is all anyone had.
|
||||
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
|
||||
const udps_signal_t& ts = frame->signals[time_signal];
|
||||
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
|
||||
for (std::uint32_t k = 0; k < n; ++k) {
|
||||
abs_stamps_[k] =
|
||||
scalar_time
|
||||
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
|
||||
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
|
||||
}
|
||||
} else {
|
||||
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
double period = model_.partition().base_dt;
|
||||
if (!(period > 0.0)) period = 1.0e-3;
|
||||
@@ -208,47 +385,89 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
}
|
||||
}
|
||||
|
||||
// A stream-time signal may be declared in a time unit rather than seconds ('ns' is
|
||||
// the common one). The scale has to be known *here*, before the checks below, because
|
||||
// they weigh the stamps' own spacing against the declared cadence, which is in
|
||||
// seconds: on a 'ns' stream an unscaled comparison is off by a factor of 1e9, so
|
||||
// every packet would read as an implausible jump.
|
||||
double unit_scale = 1.0;
|
||||
if (time_signal >= 0 && time_signal < static_cast<int>(model_.signals().size())) {
|
||||
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
||||
if (unit == "ns") unit_scale = 1e-9;
|
||||
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
||||
else if (unit == "ms") unit_scale = 1e-3;
|
||||
}
|
||||
|
||||
if (used_stream_time && n > 0) {
|
||||
// One sample's worth of the time signal's *own* unit, which is what a synthesised
|
||||
// continuation has to be spaced by and what a jump is measured against.
|
||||
const double step = cadence / unit_scale;
|
||||
// How many packets in a row must fail before the declared time signal is abandoned
|
||||
// for the arrival clock. A glitch is one packet; a signal that is not a time base
|
||||
// at all fails every one of them.
|
||||
constexpr unsigned kFailureLimit = 4;
|
||||
|
||||
// A time signal that does not actually vary -- a vector time signal, whose
|
||||
// values the client reads one element at a time, or a producer that leaves it
|
||||
// zero -- would put every sample at one x and collapse the axis to a point.
|
||||
// The arrival estimate is a worse clock but a usable one; a degenerate one is
|
||||
// not a time base at all.
|
||||
bool usable = std::isfinite(stamps_[0]);
|
||||
const char* fault = nullptr;
|
||||
double lo = stamps_[0];
|
||||
double hi = stamps_[0];
|
||||
for (std::uint32_t k = 1; usable && k < n; ++k) {
|
||||
if (!std::isfinite(stamps_[0])) fault = "carries a stamp that is not finite";
|
||||
for (std::uint32_t k = 1; fault == nullptr && k < n; ++k) {
|
||||
if (!std::isfinite(stamps_[k])) {
|
||||
usable = false;
|
||||
fault = "carries a stamp that is not finite";
|
||||
break;
|
||||
}
|
||||
lo = std::min(lo, stamps_[k]);
|
||||
hi = std::max(hi, stamps_[k]);
|
||||
}
|
||||
if (usable && hi > lo) {
|
||||
// The stamps must also ADVANCE across packets. A producer that restarts its
|
||||
// stamps every packet (or a signal mislabelled as time) keeps handing back
|
||||
// the same values; the max-clamp would then pin every sample at one x, and
|
||||
// the trigger's hold-off clock with it -- the engine waited forever and no
|
||||
// second event ever fired.
|
||||
if (have_stream_anchor_ && !(stamps_[n - 1] > last_stream_anchor_)) {
|
||||
usable = false;
|
||||
} else {
|
||||
last_stream_anchor_ = stamps_[n - 1];
|
||||
have_stream_anchor_ = true;
|
||||
if (fault == nullptr && !(hi > lo)) fault = "carries no span (every stamp is equal)";
|
||||
if (fault == nullptr && have_stream_anchor_) {
|
||||
// The stamps must also ADVANCE across packets, and by a plausible amount. A
|
||||
// producer that restarts its stamps every packet (or a signal mislabelled as
|
||||
// time) keeps handing back the same values; the max-clamp would then pin every
|
||||
// sample at one x, and the trigger's hold-off clock with it -- the engine waited
|
||||
// forever and no second event ever fired.
|
||||
if (!(stamps_[n - 1] > last_stream_anchor_)) {
|
||||
fault = "does not advance across packets";
|
||||
} else if (stamps_[0] - last_stream_anchor_ > kGlitchPackets * double(n) * step) {
|
||||
fault = "jumped implausibly far ahead of the previous packet";
|
||||
}
|
||||
}
|
||||
if (!usable || !(hi > lo)) {
|
||||
used_stream_time = false; // no span to time with; fall back to arrival
|
||||
static bool warned = false;
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
if (fault == nullptr) {
|
||||
stamp_failures_ = 0;
|
||||
last_stream_anchor_ = stamps_[n - 1];
|
||||
have_stream_anchor_ = true;
|
||||
} else if (have_stream_anchor_ && ++stamp_failures_ < kFailureLimit) {
|
||||
// A short run of bad packets is bridged rather than believed: the stamps are
|
||||
// replaced by a continuation of the accepted timeline at the declared cadence,
|
||||
// so neither the monotonic mark nor the anchor is poisoned, and the next good
|
||||
// packet -- which carries on from where the true timeline was -- is accepted
|
||||
// normally. Believing one glitching packet instead is what condemned the *next*,
|
||||
// perfectly good one and threw the whole stream onto the arrival clock.
|
||||
const double from = last_stream_anchor_;
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = from + double(k + 1) * step;
|
||||
last_stream_anchor_ = stamps_[n - 1];
|
||||
if (!warned_bridge_) {
|
||||
warned_bridge_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: the stream's time signal carries no usable span "
|
||||
"(%g..%g) or does not advance across packets; timing samples by "
|
||||
"arrival instead. Check the time signal's mode and index in the "
|
||||
"CONFIG dump above.\n",
|
||||
lo, hi);
|
||||
"[udpscope] warning: the stream's time signal %s (%g..%g against a "
|
||||
"previous %g); bridging that packet at the declared cadence and "
|
||||
"keeping the stream clock.\n",
|
||||
fault, lo, hi, from);
|
||||
}
|
||||
} else {
|
||||
used_stream_time = false; // not a time base; fall back to arrival
|
||||
if (!warned_time_source_) {
|
||||
warned_time_source_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: the stream's time signal %s (%g..%g); timing "
|
||||
"samples by arrival instead. Check the time signal's mode and index "
|
||||
"in the CONFIG dump above.\n",
|
||||
fault, lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,35 +525,31 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
if (time_source_ == 0) {
|
||||
time_source_ = used_stream_time ? 1 : 2;
|
||||
} else if (time_source_ == 1 && !used_stream_time) {
|
||||
// The declared time signal failed its checks: keep the arrival clock from here
|
||||
// on, discard the stamps already recorded on the old timeline, and make the
|
||||
// arrival clock continue from where the mark is, so the axis has no step.
|
||||
// The declared time signal failed its checks for good: keep the arrival clock from
|
||||
// here on, anchored so that it continues from the monotonic mark. The two timelines
|
||||
// then share a zero *by construction*, which is the whole point of deriving the
|
||||
// epoch from `last_stamp_` -- so the recorded history is still on one timeline and
|
||||
// is kept. It used to be wiped here, on the reasoning that the old stamps belonged
|
||||
// to a dead clock; they do not, and the wipe is what made a single glitching packet
|
||||
// look like the stream restarting from zero.
|
||||
time_source_ = 2;
|
||||
const double end = frame->recv_time;
|
||||
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
|
||||
arrival_prev_ = end;
|
||||
last_stamp_ = 0.0;
|
||||
history_reset_pending_ = true;
|
||||
}
|
||||
|
||||
// A stream-time signal may be declared in a time unit rather than seconds
|
||||
// ('ns' is the common one): scale it to seconds so the x axis is in seconds
|
||||
// either way. The scale applies ONLY to stream time: the arrival fallback
|
||||
// already produces seconds off the socket clock -- scaling those by the time
|
||||
// signal's unit shrank the whole time base by 1e9 on 'ns' streams, which
|
||||
// froze the trigger's hold-off clock and pinned the axis at ~0.
|
||||
if (used_stream_time && time_signal >= 0 &&
|
||||
time_signal < static_cast<int>(model_.signals().size())) {
|
||||
double unit_scale = 1.0;
|
||||
const std::string& unit = model_.signals()[static_cast<std::size_t>(time_signal)].unit;
|
||||
if (unit == "ns") unit_scale = 1e-9;
|
||||
else if (unit == "us" || unit == "\u00b5s" || unit == "\xce\xbc" "s") unit_scale = 1e-6;
|
||||
else if (unit == "ms") unit_scale = 1e-3;
|
||||
if (unit_scale != 1.0) {
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||
}
|
||||
// The declared unit, applied last so the x axis is in seconds either way. It applies
|
||||
// ONLY to stream time: the arrival fallback already produces seconds off the socket
|
||||
// clock -- scaling those by the time signal's unit shrank the whole time base by 1e9
|
||||
// on 'ns' streams, which froze the trigger's hold-off clock and pinned the axis at ~0.
|
||||
if (used_stream_time && unit_scale != 1.0) {
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale;
|
||||
}
|
||||
|
||||
// Stream mode: `time()` already holds the absolute clock, so the parallel ring repeats it
|
||||
// rather than carrying a second timeline. One rule for the export either way.
|
||||
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -343,10 +558,33 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||
if (!running_.load(std::memory_order_relaxed)) return;
|
||||
|
||||
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
|
||||
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
|
||||
// from the counter that revealed it.
|
||||
if (!have_counter_ || static_cast<std::int32_t>(frame->counter - last_counter_) > 0) {
|
||||
last_counter_ = frame->counter;
|
||||
have_counter_ = true;
|
||||
}
|
||||
|
||||
const Partition& part = model_.partition();
|
||||
const std::vector<ChannelInfo>& channels = model_.channels();
|
||||
if (part.time_capacity == 0 || channels.empty()) return;
|
||||
|
||||
// A frame delivered behind the sequence: the slots it owns were reserved as NaN by
|
||||
// `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
|
||||
// not stamped, does not advance `tick_`, and never reaches the trigger or the math channels:
|
||||
// the time ring was pre-stamped at reservation and those consumers are stateful and have
|
||||
// already run past this region. That is the trade the design takes -- the raw trace repairs,
|
||||
// a derived trace keeps the gap, and nothing that was computed from the gap is retrofitted.
|
||||
//
|
||||
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
|
||||
// later because `build_stamps()` is stateful: a late frame's stamps would walk the clock
|
||||
// backwards and can throw the whole stream onto the arrival clock.
|
||||
if (frame->late) {
|
||||
backfill_from(frame, part, channels, model_.live(), part.time_stride);
|
||||
return;
|
||||
}
|
||||
|
||||
// The time reference, in order of preference: the signal the first channel's
|
||||
// CONFIG entry names via `time_signal_idx`, or -- when the stream instead
|
||||
// carries its time base as an explicit full-array signal -- that signal.
|
||||
@@ -371,12 +609,6 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
}
|
||||
const std::uint32_t n = build_stamps(frame, time_signal);
|
||||
if (n == 0) return;
|
||||
// A change of time source invalidated the recorded history: empty the rings before
|
||||
// anything more goes in, so no reader can find two timelines under one index.
|
||||
if (history_reset_pending_) {
|
||||
history_reset_pending_ = false;
|
||||
model_.reset_live_history_locked(); // this thread holds the model lock
|
||||
}
|
||||
|
||||
if (model_.epoch() == 0.0) {
|
||||
model_.set_epoch_if_unset(stamps_[0]);
|
||||
@@ -395,6 +627,26 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
BufferStore& store = model_.live();
|
||||
const u64 g = part.time_stride;
|
||||
|
||||
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
|
||||
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
|
||||
// only an absolute slot index while `tick_ / period == produced()`, so the tick counter has
|
||||
// to restart with the buffer -- otherwise the amend lands outside the ring, repairs nothing,
|
||||
// and the hole is still marked repaired, which is the band saying the opposite of the truth.
|
||||
// The generation is the signal rather than the stride/channel-count comparison below: only
|
||||
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
|
||||
// keeps it, history and all (see `ScopeModel::set_signals`).
|
||||
// `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
|
||||
// since the stream began, and relative time must not jump backwards when the user retypes
|
||||
// the window.
|
||||
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
|
||||
seen_plan_generation_ = gen;
|
||||
tick_ = 0;
|
||||
last_tick_ = ~std::uint64_t{0};
|
||||
// Zeroing the stride makes the guard below fire too: the accumulators belong to the group
|
||||
// `tick_` was counting through, so they are stale for the same reason.
|
||||
acc_stride_ = 0;
|
||||
}
|
||||
|
||||
// The filter's groups are `g` long, so a re-plan has to start a fresh group rather than
|
||||
// finish the one it was in with the wrong length.
|
||||
if (acc_stride_ != g || acc_channels_ != channels.size()) {
|
||||
@@ -404,9 +656,39 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
value_acc_n_.assign(channels.size(), 0);
|
||||
math_sample_.assign(channels.size(), std::nanf(""));
|
||||
time_acc_ = 0.0;
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
// The gap the counter reports, reserved as NaN before this frame's own samples are
|
||||
// stamped: the hole belongs *ahead* of the data that revealed it. Running the missing
|
||||
// samples through the same per-sample loop below is the whole trick -- `tick_` advances
|
||||
// so the decimation phase does not shift, the group mean lands on the mean stamp, and
|
||||
// `MinMaxPyramid::append` sees NaN, which its non-finite contract already stores as an
|
||||
// empty aggregate every reader treats as absent.
|
||||
//
|
||||
// Capped at `kGlitchPackets`: 256 packet spans in one go is an outage, not loss, and
|
||||
// reserving one would push the entire retained history out of the ring to make room for
|
||||
// samples that are not coming.
|
||||
//
|
||||
// This block must follow the re-plan above: reserve_hole() runs the missing samples
|
||||
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
|
||||
// which must already be sized to the current channel count and stride before any
|
||||
// per-sample write.
|
||||
const std::uint32_t missing = frame->lost;
|
||||
if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
|
||||
reserve_hole(missing, part, channels, store, g);
|
||||
} else if (missing > 0 && !warned_outage_) {
|
||||
warned_outage_ = true;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] warning: %u packets missing in one go, past the %g-packet "
|
||||
"reservation cap; treating it as an outage and carrying on rather than "
|
||||
"reserving %llu samples of hole.\n",
|
||||
missing, kGlitchPackets,
|
||||
static_cast<unsigned long long>(std::uint64_t(missing) * batch_));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
// Calibration is read per frame; it only changes under the same lock.
|
||||
TriggerEngine& engine = engine_;
|
||||
const int trig_channel = tc.channel;
|
||||
@@ -427,7 +709,14 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
const double post_want = tc.post_span_s(event_span);
|
||||
const double oldest_s =
|
||||
store.time().size() > 0 ? double(store.time()[0]) : last_stamp_;
|
||||
engine.configure(tc, post_want, event_span - post_want, oldest_s);
|
||||
// The channel's *name* goes with it, because that is what identifies it across a
|
||||
// rebuild of the channel list (see `TriggerEngine::configure`).
|
||||
static const std::string kNoTriggerChannel;
|
||||
const std::string& trig_name =
|
||||
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
|
||||
? channels[static_cast<std::size_t>(trig_channel)].name
|
||||
: kNoTriggerChannel;
|
||||
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s);
|
||||
// The engine's armed state follows the panel's, both ways: without the disarm
|
||||
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
|
||||
// re-entering trigger mode kept the stale prev/edge/hold-off state.
|
||||
@@ -516,10 +805,18 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
// boxcar's group delay puts the averaged value, and stamping it at the end of the
|
||||
// group would shift every trace half a stored sample late.
|
||||
time_acc_ += rel;
|
||||
last_abs_stamp_ = abs_stamps_[k] - epoch;
|
||||
abs_time_acc_ += last_abs_stamp_;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||
time_acc_ = 0.0;
|
||||
// The absolute clock on the same slot and under the same group mean, so an export can
|
||||
// pair value, relative stamp and absolute stamp by index with no interpolation.
|
||||
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||
abs->push(abs_time_acc_ / static_cast<double>(time_acc_n_));
|
||||
}
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
@@ -614,6 +911,157 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
}
|
||||
}
|
||||
|
||||
void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||
u64 g) {
|
||||
const u64 raw = u64(missing) * u64(batch_);
|
||||
Hole h;
|
||||
h.tick_begin = tick_;
|
||||
h.slot_begin = store.time().produced();
|
||||
// `lost` counts the packets missing *immediately before* the frame that revealed them
|
||||
// (see `udps_frame_t::lost`), and the counter mark at the top of `on_data` has already
|
||||
// moved `last_counter_` to that frame's own counter -- so the hole spans
|
||||
// [counter - missing, counter). This read `last_counter_ + 1u`, which addressed a span
|
||||
// one past the revealing frame and `missing + 1` too high: no late packet's counter could
|
||||
// fall in it, so every backfill lookup missed and `backfilled` read 0 against 154 holes.
|
||||
h.counter_first = last_counter_ - missing;
|
||||
h.packets = missing;
|
||||
|
||||
double dt = part.base_dt;
|
||||
if (!(dt > 0.0)) dt = 1.0e-3;
|
||||
for (u64 j = 0; j < raw; ++j) {
|
||||
const u64 tick = tick_++;
|
||||
// The hole is stamped at the declared cadence from the last good stamp -- exact in
|
||||
// relative mode, and in stream mode the same continuation the glitch bridge makes.
|
||||
// It must be *stamped* rather than left short: the time ring is binary-searched by
|
||||
// `ChannelBuffer::neighbours()`, so it has to stay monotone and dense, and backfill
|
||||
// then changes values only and never a stamp.
|
||||
const double rel = last_stamp_ + dt;
|
||||
last_stamp_ = rel;
|
||||
last_abs_stamp_ += dt;
|
||||
if (j == 0) h.x_begin = rel;
|
||||
h.x_end = rel;
|
||||
|
||||
time_acc_ += rel;
|
||||
abs_time_acc_ += last_abs_stamp_;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
store.time().push(time_acc_ / double(time_acc_n_));
|
||||
// Null in stream mode, where `time()` already is the absolute clock.
|
||||
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
|
||||
abs->push(abs_time_acc_ / double(time_acc_n_));
|
||||
}
|
||||
time_acc_ = 0.0;
|
||||
abs_time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (channels[i].math) continue;
|
||||
// A NaN in the accumulator makes the whole group NaN, which is why a slot straddling
|
||||
// a hole edge is NaN too: its real members were averaged into a sum that is now
|
||||
// non-finite and were never retained, so the backfill cannot recover it and must not
|
||||
// pretend to. One slot per edge, against a hole thousands of slots wide.
|
||||
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
|
||||
++value_acc_n_[i];
|
||||
const u64 period = g * part.stride[i];
|
||||
if (tick % period != period - 1) continue;
|
||||
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
|
||||
value_acc_[i] = 0.0;
|
||||
value_acc_n_[i] = 0;
|
||||
}
|
||||
// The math channels are fed NaN like everything else, so a derived trace keeps the gap
|
||||
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
|
||||
// could not latch here anyway, and feeding it would advance its hold-off clock over
|
||||
// time nothing was sampled in.
|
||||
if (tick % g == g - 1) {
|
||||
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
|
||||
model_.push_math_sample(math_sample_);
|
||||
}
|
||||
}
|
||||
h.slot_count = store.time().produced() - h.slot_begin;
|
||||
model_.note_hole(h);
|
||||
model_.stats().holes.fetch_add(1, std::memory_order_relaxed);
|
||||
model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store,
|
||||
u64 g) {
|
||||
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
|
||||
// before any good one therefore has no packet length to size its span with, and no hole
|
||||
// could have been reserved for it either.
|
||||
if (batch_ == 0) return;
|
||||
|
||||
// Which hole this packet belongs to, by counter: that is the whole reason relative timing
|
||||
// and resequencing are one design. A stamp could not answer it -- the packet's own stamps
|
||||
// are a claim about a timeline the ring has moved past -- but the counter indexes the
|
||||
// reservation table directly.
|
||||
// By signed delta, the idiom the client uses throughout: the counter is a wrapping uint32, so
|
||||
// a raw `>=`/`<` pair matches nothing for a hole straddling the 2^32 wrap and every one of its
|
||||
// late packets would be dropped on the next line.
|
||||
Hole* hole = nullptr;
|
||||
for (Hole& h : model_.holes()) {
|
||||
const std::int32_t into = static_cast<std::int32_t>(frame->counter - h.counter_first);
|
||||
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
|
||||
hole = &h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hole == nullptr) return; // its hole has already slid out of the ring, or never existed
|
||||
|
||||
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
|
||||
// and the packets inside a hole are consecutive, so the counter's distance from
|
||||
// `counter_first` places this one exactly.
|
||||
const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_);
|
||||
const u64 hi = lo + u64(batch_);
|
||||
|
||||
u64 filled = 0;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
// A derived channel has no packet signal to read, and the design does not retrofit it:
|
||||
// its gap stays.
|
||||
if (channels[i].math) continue;
|
||||
const u64 period = g * part.stride[i];
|
||||
// Only slots lying *wholly* inside this packet's span are repaired. A slot straddling the
|
||||
// span's edge mixes this packet's samples with ones still missing, and its real members
|
||||
// were never retained -- `reserve_hole()` averaged a NaN into them -- so it stays NaN, and
|
||||
// a displayed hole is at most one slot wider per edge than the true loss.
|
||||
const u64 first_slot = (lo + period - 1) / period;
|
||||
const u64 last_slot = hi / period;
|
||||
for (u64 s = first_slot; s < last_slot; ++s) {
|
||||
// The same boxcar mean the live path stores, over the same group of raw samples: the
|
||||
// whole group is inside this packet, so the average is exact and not an approximation
|
||||
// of what the live path would have written.
|
||||
double acc = 0.0;
|
||||
for (u64 j = 0; j < period; ++j) {
|
||||
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
|
||||
const double raw =
|
||||
channels[i].elements > 1
|
||||
? udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal), 0,
|
||||
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
|
||||
channels[i].elements - 1))
|
||||
: udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal),
|
||||
static_cast<std::uint32_t>(k), 0);
|
||||
acc += raw * channels[i].gain + channels[i].offset;
|
||||
}
|
||||
const f32 value = static_cast<f32>(acc / double(period));
|
||||
// The ring first, by the producer that owns its writes; the pyramid is queued for the
|
||||
// consumer (see `ChannelBuffer::queue_amend`). A slot the ring has already discarded
|
||||
// returns false, and then there is no aggregate to amend either.
|
||||
if (store.values(i).amend(s, value)) {
|
||||
store.channel(i).queue_amend(s, value);
|
||||
++filled;
|
||||
}
|
||||
}
|
||||
}
|
||||
// This packet's share of the hole has been delivered, whatever the ring still retains of it.
|
||||
// Marked on *arrival* and not on `filled`: a decimation coarse enough that no whole slot lies
|
||||
// inside one packet repairs no slot at all, and keying off `filled` would leave such a hole
|
||||
// permanently unrepairable -- a solid band over data that is all there, which is the exact
|
||||
// defect this task closes.
|
||||
++hole->filled_packets;
|
||||
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Static trampolines: the client's C ABI cannot call a member function directly.
|
||||
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
||||
std::uint8_t publish_mode, void* user) {
|
||||
|
||||
+84
-3
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "capture.hpp"
|
||||
#include "config.hpp"
|
||||
#include "model.hpp"
|
||||
#include "trigger.hpp"
|
||||
@@ -12,6 +13,7 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
@@ -37,6 +39,19 @@ class Acquisition {
|
||||
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Turns the packet capture on or off -- the whole GTK side of the feature. The
|
||||
/// path is built here, once, *before* the flag is published, so the producer reads
|
||||
/// a complete string whenever it reads a true flag. `slots` is the ring's size;
|
||||
/// zero means the 1000-slot default. The producer does the open or the close at the
|
||||
/// top of the next datagram, so a toggle costs no lock and no second thread.
|
||||
void set_capture_want(bool want, std::uint32_t slots = 0);
|
||||
bool capture_want() const noexcept {
|
||||
return capture_want_.load(std::memory_order_acquire);
|
||||
}
|
||||
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||
/// cross-thread through PacketCapture's own published fields.
|
||||
const PacketCapture& capture() const noexcept { return capture_; }
|
||||
|
||||
private:
|
||||
void run();
|
||||
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
||||
@@ -47,18 +62,48 @@ class Acquisition {
|
||||
std::uint8_t publish_mode, void* user);
|
||||
static void data_trampoline(const udps_frame_t* frame, void* user);
|
||||
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
||||
static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
|
||||
double recv_time, void* user);
|
||||
/// Drops the producer state that is per *connection*: the counter mark, the time
|
||||
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
|
||||
/// have restarted its numbering; carrying the old session's marks across makes the
|
||||
/// first new frames read as late, as glitches or as an outage, which is recovery
|
||||
/// failing exactly when it matters. Called on the producer thread, from on_event().
|
||||
void reset_session_state() noexcept;
|
||||
/// The producer side of the toggle: open on a rising edge, close on a falling one,
|
||||
/// then store if enabled. Every file operation happens here, on this thread.
|
||||
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
|
||||
|
||||
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
||||
/// and returns how many were written (`frame->num_samples` unless the frame is
|
||||
/// malformed).
|
||||
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
||||
|
||||
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
||||
/// advances the decimation accumulators and tick_, records the hole in the model, and
|
||||
/// bumps the hole counters. Called before the frame's own samples so the gap sits
|
||||
/// *ahead* of the data that revealed it.
|
||||
void reserve_hole(std::uint32_t missing, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||
|
||||
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
|
||||
/// amending the value ring directly (producer path) and queuing pyramid amends for
|
||||
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
||||
void backfill_from(const udps_frame_t* frame, const Partition& part,
|
||||
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||
|
||||
ScopeModel& model_;
|
||||
Config cfg_;
|
||||
udps_client* client_ = nullptr;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
PacketCapture capture_;
|
||||
std::atomic<bool> capture_want_{false};
|
||||
std::uint32_t capture_slots_ = 1000;
|
||||
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
||||
/// the producer reads it only after the flag, so no lock is needed.
|
||||
std::string capture_path_;
|
||||
TriggerEngine engine_;
|
||||
|
||||
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||
@@ -70,6 +115,11 @@ class Acquisition {
|
||||
/// included -- so the stream source is dropped in favour of arrival time.
|
||||
double last_stream_anchor_ = 0.0;
|
||||
bool have_stream_anchor_ = false;
|
||||
/// Consecutive packets whose stream-time stamps failed those checks. A single bad
|
||||
/// packet is a glitch in the producer's clock and is bridged at the declared cadence;
|
||||
/// only a run of them means the declared signal is not a time base, and only then is
|
||||
/// the arrival clock taken. Any accepted packet resets it.
|
||||
unsigned stamp_failures_ = 0;
|
||||
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
||||
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
||||
double arrival_epoch_ = 0.0;
|
||||
@@ -85,10 +135,40 @@ class Acquisition {
|
||||
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
||||
/// time base no reader can make sense of (see `build_stamps`).
|
||||
int time_source_ = 0;
|
||||
/// Set when the source changed under the recorded history: the rings have to be
|
||||
/// emptied before more samples go in.
|
||||
bool history_reset_pending_ = false;
|
||||
/// Relative-time mode's position counter: samples seen since the stream began, which is
|
||||
/// what the counter names. Advanced by `batch_` for every packet the counter reports
|
||||
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is
|
||||
/// exactly `counter * batch + sample_index` for a constant batch and degrades gracefully
|
||||
/// when a frame's length disagrees.
|
||||
std::uint64_t slot_base_ = 0;
|
||||
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
|
||||
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
|
||||
std::uint32_t batch_ = 0;
|
||||
/// Latches the one warning about a frame whose length disagrees with the stream's first.
|
||||
bool warned_batch_ = false;
|
||||
/// The newest counter seen, and the hole's address. Tracked here rather than inferred from
|
||||
/// `frame->lost` because a *late* frame's counter is behind the sequence and must not move
|
||||
/// the mark forward.
|
||||
std::uint32_t last_counter_ = 0;
|
||||
bool have_counter_ = false;
|
||||
/// Last absolute stamp written, epoch-relative, so a reserved hole can continue the
|
||||
/// absolute clock at the declared cadence. The packets that would have carried the real
|
||||
/// stamps never arrived, so a cadence continuation is the only honest value available.
|
||||
double last_abs_stamp_ = 0.0;
|
||||
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
|
||||
/// `static`, matching the latch Task 2 added for the same reason.
|
||||
bool warned_outage_ = false;
|
||||
/// Warn-once latch for the one bridged packet whose time signal glitched.
|
||||
bool warned_bridge_ = false;
|
||||
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
|
||||
/// taken instead.
|
||||
bool warned_time_source_ = false;
|
||||
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
|
||||
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
|
||||
/// reset when this falls behind `ScopeModel::plan_generation()`.
|
||||
unsigned seen_plan_generation_ = 0;
|
||||
std::vector<double> stamps_;
|
||||
std::vector<double> abs_stamps_;
|
||||
/// The decimation filter's state: a running sum per channel (and one for the shared
|
||||
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
||||
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
||||
@@ -101,6 +181,7 @@ class Acquisition {
|
||||
std::vector<f32> math_sample_;
|
||||
std::vector<std::uint64_t> value_acc_n_;
|
||||
double time_acc_ = 0.0;
|
||||
double abs_time_acc_ = 0.0;
|
||||
std::uint64_t time_acc_n_ = 0;
|
||||
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
||||
/// averaging across the boundary.
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "capture.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// The file header is one 64-byte block: fixed fields first, zeroed padding after.
|
||||
constexpr std::uint64_t kHeaderBytes = 64;
|
||||
/// `seq` lives at the *end* of a slot, at a fixed offset, because the reader must
|
||||
/// find it without trusting any other field.
|
||||
constexpr std::uint64_t kSeqBytes = 8;
|
||||
|
||||
void put_u32(std::uint8_t* p, std::uint32_t v) { std::memcpy(p, &v, 4); }
|
||||
void put_u16(std::uint8_t* p, std::uint16_t v) { std::memcpy(p, &v, 2); }
|
||||
void put_f64(std::uint8_t* p, double v) { std::memcpy(p, &v, 8); }
|
||||
std::uint32_t get_u32(const std::uint8_t* p) {
|
||||
std::uint32_t v;
|
||||
std::memcpy(&v, p, 4);
|
||||
return v;
|
||||
}
|
||||
double get_f64(const std::uint8_t* p) {
|
||||
double v;
|
||||
std::memcpy(&v, p, 8);
|
||||
return v;
|
||||
}
|
||||
|
||||
double wall_seconds() {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return double(ts.tv_sec) + double(ts.tv_nsec) * 1e-9;
|
||||
}
|
||||
|
||||
/// `seq` is published with a release store *after* the bytes are down, so a reader
|
||||
/// that sees a non-zero `seq` sees the whole slot -- the reason no `fsync` is needed
|
||||
/// for the file to be readable after a crash. Plain __atomic builtins, not
|
||||
/// `std::atomic_ref`: the target is mmap'd memory the class does not own as an object.
|
||||
void publish_seq(std::uint8_t* slot, std::uint64_t seq) {
|
||||
__atomic_store_n(reinterpret_cast<std::uint64_t*>(slot + kSlotBytes - kSeqBytes), seq,
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
std::uint64_t read_seq(const std::uint8_t* slot) {
|
||||
return __atomic_load_n(reinterpret_cast<const std::uint64_t*>(slot + kSlotBytes - kSeqBytes),
|
||||
__ATOMIC_ACQUIRE);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PacketCapture::set_error(const char* what) noexcept {
|
||||
std::snprintf(error_, sizeof(error_), "%s", what);
|
||||
error_set_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void PacketCapture::error(char* out, std::size_t cap) const noexcept {
|
||||
if (cap == 0) return;
|
||||
out[0] = '\0';
|
||||
if (!error_set_.load(std::memory_order_acquire)) return;
|
||||
std::snprintf(out, cap, "%s", error_);
|
||||
}
|
||||
|
||||
bool PacketCapture::open(const std::string& path, std::uint32_t slot_count) {
|
||||
close();
|
||||
path_ = path;
|
||||
slot_count_ = slot_count;
|
||||
seq_ = 0;
|
||||
cursor_ = 0;
|
||||
stored_.store(0, std::memory_order_relaxed);
|
||||
truncated_.store(0, std::memory_order_relaxed);
|
||||
error_set_.store(false, std::memory_order_relaxed);
|
||||
|
||||
const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
|
||||
|
||||
// Keep the previous capture one deep: re-enabling must not destroy the capture the
|
||||
// user enabled *for*. A failure here is reported, and the old previous kept.
|
||||
std::string prev = path;
|
||||
const std::size_t slash = prev.rfind('/');
|
||||
const std::size_t dot = prev.rfind('.');
|
||||
if (dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
|
||||
prev.insert(dot, "-prev");
|
||||
} else {
|
||||
prev += "-prev";
|
||||
}
|
||||
::rename(path.c_str(), prev.c_str());
|
||||
|
||||
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
||||
if (fd_ < 0) {
|
||||
set_error(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reserved, not sparse: a store into a page the filesystem cannot back raises
|
||||
// SIGBUS and kills udpscope outright, and an out-of-space condition must be an
|
||||
// error at the moment the user clicked, not a crash ten minutes later.
|
||||
const std::uint64_t total = kHeaderBytes + std::uint64_t(slot_count) * kSlotBytes;
|
||||
if (::posix_fallocate(fd_, 0, off_t(total)) != 0) {
|
||||
set_error(strerror(errno));
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
::unlink(path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
map_ = static_cast<std::uint8_t*>(
|
||||
::mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0));
|
||||
if (map_ == MAP_FAILED) {
|
||||
map_ = nullptr;
|
||||
set_error(strerror(errno));
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
::unlink(path.c_str());
|
||||
return false;
|
||||
}
|
||||
map_bytes_ = total;
|
||||
|
||||
std::uint8_t* h = map_;
|
||||
std::memset(h, 0, kHeaderBytes);
|
||||
std::memcpy(h, "UDPSCAP\0", 8);
|
||||
put_u32(h + 8, 1); // version
|
||||
put_u32(h + 12, slot_count);
|
||||
put_u32(h + 16, kSlotBytes);
|
||||
put_u32(h + 20, 0); // cursor: a hint, `seq` is the order
|
||||
put_f64(h + 24, wall_seconds()); // the epoch recv_time is relative to
|
||||
put_u32(h + 32, peer_addr_);
|
||||
put_u16(h + 36, peer_port_);
|
||||
put_u16(h + 38, 0); // local_port: the client does not expose its own socket
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
open_ms_.store(ms, std::memory_order_relaxed);
|
||||
|
||||
enabled_.store(true, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PacketCapture::close() noexcept {
|
||||
if (map_ != nullptr) {
|
||||
::munmap(map_, map_bytes_);
|
||||
map_ = nullptr;
|
||||
}
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
}
|
||||
map_bytes_ = 0;
|
||||
enabled_.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void PacketCapture::store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept {
|
||||
if (!enabled_.load(std::memory_order_relaxed) || map_ == nullptr) return;
|
||||
|
||||
std::uint8_t* slot = map_ + kHeaderBytes + std::uint64_t(cursor_) * kSlotBytes;
|
||||
std::uint32_t orig = std::uint32_t(len);
|
||||
std::size_t fit = len;
|
||||
if (fit > kMaxDatagramBytes) {
|
||||
fit = kMaxDatagramBytes;
|
||||
truncated_.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
put_f64(slot, recv_time);
|
||||
put_u32(slot + 8, std::uint32_t(fit));
|
||||
put_u32(slot + 12, orig);
|
||||
std::memcpy(slot + kRecordHeaderBytes, bytes, fit);
|
||||
++seq_;
|
||||
publish_seq(slot, seq_); // last: a non-zero `seq` is what makes a slot complete
|
||||
|
||||
cursor_ = (cursor_ + 1) % slot_count_;
|
||||
put_u32(map_ + 20, cursor_); // a hint for a human in xxd; `seq` is the order
|
||||
stored_.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool read_capture(const std::string& path, CaptureReadStats* out) {
|
||||
if (out == nullptr) return false;
|
||||
*out = CaptureReadStats{};
|
||||
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
if (f == nullptr) return false;
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long size = std::ftell(f);
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
out->file_bytes = std::uint64_t(size < 0 ? 0 : size);
|
||||
|
||||
std::uint8_t h[kHeaderBytes];
|
||||
if (std::fread(h, 1, sizeof(h), f) != sizeof(h)) {
|
||||
std::fclose(f);
|
||||
return false;
|
||||
}
|
||||
out->header_ok = std::memcmp(h, "UDPSCAP\0", 8) == 0 && get_u32(h + 8) == 1;
|
||||
out->slot_count = get_u32(h + 12);
|
||||
out->slot_bytes = get_u32(h + 16);
|
||||
out->cursor = get_u32(h + 20);
|
||||
out->t0_realtime = get_f64(h + 24);
|
||||
if (!out->header_ok || out->slot_count == 0 || out->slot_bytes < kRecordHeaderBytes + 8) {
|
||||
std::fclose(f);
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint32_t payload = out->slot_bytes - kRecordHeaderBytes - kSeqBytes;
|
||||
std::vector<std::uint8_t> slot(out->slot_bytes);
|
||||
for (std::uint32_t i = 0; i < out->slot_count; ++i) {
|
||||
if (std::fseek(f, long(kHeaderBytes + std::uint64_t(i) * out->slot_bytes), SEEK_SET) != 0) {
|
||||
break;
|
||||
}
|
||||
if (std::fread(slot.data(), 1, slot.size(), f) != slot.size()) break;
|
||||
const std::uint64_t seq = read_seq(slot.data());
|
||||
const std::uint32_t len = get_u32(slot.data() + 8);
|
||||
const std::uint32_t orig = get_u32(slot.data() + 12);
|
||||
if (seq == 0 && len == 0 && orig == 0) {
|
||||
++out->empty;
|
||||
continue;
|
||||
}
|
||||
if (seq == 0) {
|
||||
++out->torn; // bytes down, ordinal never published: a capture that died mid-copy
|
||||
continue;
|
||||
}
|
||||
++out->stored;
|
||||
out->payload += len;
|
||||
if (orig > payload) ++out->truncated;
|
||||
if (out->first_seq == 0 || seq < out->first_seq) out->first_seq = seq;
|
||||
if (seq > out->last_seq) out->last_seq = seq;
|
||||
}
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The largest datagram the client's socket can deliver, and therefore the payload
|
||||
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
|
||||
/// over-long datagram is impossible by construction, and `store()` counts the
|
||||
/// impossibility rather than trusting it.
|
||||
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
|
||||
|
||||
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
|
||||
/// word that is written last. 8-byte aligned.
|
||||
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
|
||||
inline constexpr std::uint32_t kSlotBytes =
|
||||
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
|
||||
|
||||
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
|
||||
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
|
||||
/// whole world is bytes, a clock reading and one file.
|
||||
///
|
||||
/// Everything here runs on the producer thread except `open()`'s caller choice and
|
||||
/// the GTK thread's reads of the counters and the error, which are publishable
|
||||
/// without a lock: see the field table in the design note
|
||||
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
|
||||
class PacketCapture {
|
||||
public:
|
||||
PacketCapture() = default;
|
||||
~PacketCapture() { close(); }
|
||||
|
||||
PacketCapture(const PacketCapture&) = delete;
|
||||
PacketCapture& operator=(const PacketCapture&) = delete;
|
||||
|
||||
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
|
||||
/// creates and reserves the new file, maps it, writes the header. Returns false
|
||||
/// and leaves the object disabled on any failure, with `error()` set.
|
||||
bool open(const std::string& path, std::uint32_t slot_count);
|
||||
|
||||
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
|
||||
void close() noexcept;
|
||||
|
||||
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// One datagram. Called only from the producer thread. Never allocates, never
|
||||
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
|
||||
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||
|
||||
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
|
||||
std::uint64_t truncated() const noexcept {
|
||||
return truncated_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint32_t slot_count() const noexcept { return slot_count_; }
|
||||
const std::string& path() const noexcept { return path_; }
|
||||
|
||||
/// The source's IPv4, in network order, and its port: written into the header at
|
||||
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
|
||||
/// from the thread that owns the decision -- the same write-once discipline as the
|
||||
/// path. `local_port` stays zero: the client does not expose its own socket.
|
||||
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
|
||||
peer_addr_ = addr_net_order;
|
||||
peer_port_ = port;
|
||||
}
|
||||
|
||||
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
|
||||
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
|
||||
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Copies the last open() failure out, or writes an empty string. Deliberately not
|
||||
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
|
||||
/// the flag with a release store, and the GTK thread copies only when the flag reads
|
||||
/// true -- a `std::string` shared like that is a data race with a reallocating
|
||||
/// buffer, the kind that reads correctly for months.
|
||||
void error(char* out, std::size_t cap) const noexcept;
|
||||
|
||||
private:
|
||||
void set_error(const char* what) noexcept;
|
||||
|
||||
std::string path_;
|
||||
std::uint32_t slot_count_ = 0;
|
||||
int fd_ = -1;
|
||||
std::uint8_t* map_ = nullptr; ///< whole file, header first
|
||||
std::uint64_t map_bytes_ = 0;
|
||||
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
|
||||
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
|
||||
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
|
||||
std::uint16_t peer_port_ = 0;
|
||||
std::atomic<bool> enabled_{false};
|
||||
std::atomic<std::uint64_t> stored_{0};
|
||||
std::atomic<std::uint64_t> truncated_{0};
|
||||
std::atomic<bool> error_set_{false};
|
||||
std::atomic<double> open_ms_{0.0};
|
||||
mutable char error_[128] = {0};
|
||||
};
|
||||
|
||||
/// What a capture file says when read back off disk. `stored` counts slots with a
|
||||
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
|
||||
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
|
||||
struct CaptureReadStats {
|
||||
bool header_ok = false;
|
||||
std::uint32_t slot_count = 0;
|
||||
std::uint32_t slot_bytes = 0;
|
||||
std::uint32_t cursor = 0;
|
||||
double t0_realtime = 0.0;
|
||||
std::uint64_t stored = 0;
|
||||
std::uint64_t torn = 0;
|
||||
std::uint64_t empty = 0;
|
||||
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
|
||||
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
|
||||
std::uint64_t first_seq = 0;
|
||||
std::uint64_t last_seq = 0;
|
||||
std::uint64_t file_bytes = 0;
|
||||
};
|
||||
|
||||
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
|
||||
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
|
||||
bool read_capture(const std::string& path, CaptureReadStats* out);
|
||||
|
||||
} // namespace udpscope
|
||||
+37
-27
@@ -31,41 +31,51 @@ ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>>
|
||||
|
||||
void ChannelBuffer::acquire() noexcept {
|
||||
const u64 produced = values_->produced();
|
||||
if (produced <= published_) {
|
||||
// Nothing new; still allow the window to slide if another reader dropped it.
|
||||
return;
|
||||
// Nothing new skips the append pass only: the backfill drain at the end of this function
|
||||
// runs either way, because a late packet can land in a frame interval that pushed nothing.
|
||||
if (produced > published_) {
|
||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||
// not being published for a while -- makes the incremental append meaningless:
|
||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
||||
// way the published window stays coherent (an empty or garbled window is what
|
||||
// "the trace disappeared" looks like from outside).
|
||||
if (produced - published_ > values_->capacity()) {
|
||||
published_ = 0;
|
||||
pyramid_.clear();
|
||||
}
|
||||
f32 chunk[kChunk];
|
||||
for (u64 i = published_; i < produced;) {
|
||||
const u64 take = std::min<u64>(kChunk, produced - i);
|
||||
values_->copy_from(i, take, chunk);
|
||||
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
||||
i += take;
|
||||
}
|
||||
published_ = produced;
|
||||
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
||||
// one still retained. drop_before() is monotonic and cheap.
|
||||
pyramid_.drop_before(values_->base());
|
||||
// And the window's first stamp with it, here and nowhere else (see the member's note): the
|
||||
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
||||
// place in the object's life where that sample's slot is safe to read.
|
||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||
}
|
||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||
// not being published for a while -- makes the incremental append meaningless:
|
||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
||||
// way the published window stays coherent (an empty or garbled window is what
|
||||
// "the trace disappeared" looks like from outside).
|
||||
if (produced - published_ > values_->capacity()) {
|
||||
published_ = 0;
|
||||
pyramid_.clear();
|
||||
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
||||
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
||||
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
||||
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
||||
// behind the old early return would have left the pyramid stale until the next push.
|
||||
if (!pending_amends_.empty()) {
|
||||
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
||||
pending_amends_.clear();
|
||||
}
|
||||
f32 chunk[kChunk];
|
||||
for (u64 i = published_; i < produced;) {
|
||||
const u64 take = std::min<u64>(kChunk, produced - i);
|
||||
values_->copy_from(i, take, chunk);
|
||||
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
||||
i += take;
|
||||
}
|
||||
published_ = produced;
|
||||
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
||||
// one still retained. drop_before() is monotonic and cheap.
|
||||
pyramid_.drop_before(values_->base());
|
||||
// And the window's first stamp with it, here and nowhere else (see the member's note): the
|
||||
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
||||
// place in the object's life where that sample's slot is safe to read.
|
||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||
}
|
||||
|
||||
void ChannelBuffer::invalidate() noexcept {
|
||||
published_ = 0;
|
||||
window_x0_ = 0.0;
|
||||
pyramid_.clear();
|
||||
pending_amends_.clear();
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
|
||||
@@ -49,6 +49,17 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
||||
void invalidate() noexcept;
|
||||
|
||||
/// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute`
|
||||
/// has been backfilled, for `acquire()` to fold into the pyramid.
|
||||
///
|
||||
/// The pyramid is mutated *only* on the consumer thread, because a bucket is a 32-byte
|
||||
/// non-atomic aggregate the renderer reads unlocked at paint time: amending it from the
|
||||
/// producer would let a reader see a half-updated bucket whose `sum` did not match its
|
||||
/// `count`, which is a wrong mean out of a structure whose readers trust `count == 0` to
|
||||
/// mean absent. The value ring is amended by the producer, which owns its writes; the
|
||||
/// pyramid waits for here.
|
||||
void queue_amend(u64 absolute, f32 v) { pending_amends_.push_back({absolute, v}); }
|
||||
|
||||
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
||||
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
||||
/// the axis read seconds from the trigger event instead of from the stream.
|
||||
@@ -100,6 +111,13 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||
f64 time_of(u64 absolute_index) const noexcept;
|
||||
|
||||
struct Amend {
|
||||
u64 absolute;
|
||||
f32 value;
|
||||
};
|
||||
/// Backfills the producer has written into the value ring and the pyramid has not seen yet.
|
||||
std::vector<Amend> pending_amends_;
|
||||
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||
u64 stride_ = 1;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "condition.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
void EventDetector::configure(const EventCondition& c) noexcept {
|
||||
if (c == cond_) return;
|
||||
cond_ = c;
|
||||
reset();
|
||||
}
|
||||
|
||||
void EventDetector::reset() noexcept {
|
||||
have_prev_ = false;
|
||||
prev_ = 0.0;
|
||||
in_pulse_ = false;
|
||||
pulse_up_ = false;
|
||||
pulse_start_s_ = 0.0;
|
||||
inside_ = false;
|
||||
last_cross_s_ = 0.0;
|
||||
dropped_ = false;
|
||||
}
|
||||
|
||||
double EventDetector::feed(double value, double t) noexcept {
|
||||
if (!have_prev_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
// The window condition needs a side to change *from*, and the dropout a last-seen time
|
||||
// to count from; neither can be derived from a crossing that has not happened. Seeding
|
||||
// them here is what lets both handlers run without a "have we started" guard.
|
||||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||
inside_ = value >= lo && value <= hi;
|
||||
last_cross_s_ = t;
|
||||
dropped_ = false;
|
||||
return kNoEvent;
|
||||
}
|
||||
double event = kNoEvent;
|
||||
switch (cond_.type) {
|
||||
case TriggerType::Edge:
|
||||
event = feed_edge(value, t);
|
||||
break;
|
||||
case TriggerType::PulseWidth:
|
||||
event = feed_pulse(value, t);
|
||||
break;
|
||||
case TriggerType::Window:
|
||||
event = feed_window(value, t);
|
||||
break;
|
||||
case TriggerType::Dropout:
|
||||
event = feed_dropout(value, t);
|
||||
break;
|
||||
}
|
||||
// After the handler: every crossing test above reads `prev_` as the previous sample.
|
||||
prev_ = value;
|
||||
return event;
|
||||
}
|
||||
|
||||
double EventDetector::feed_edge(double value, double t) noexcept {
|
||||
const bool up = crossed_up(value);
|
||||
const bool down = crossed_down(value);
|
||||
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
return fired ? t : kNoEvent;
|
||||
}
|
||||
|
||||
bool EventDetector::qualifies(double width_s) const noexcept {
|
||||
const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s;
|
||||
const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s;
|
||||
switch (cond_.cmp) {
|
||||
case WidthCmp::Less:
|
||||
return width_s < cond_.width_s;
|
||||
case WidthCmp::Greater:
|
||||
return width_s > cond_.width_s;
|
||||
case WidthCmp::Within:
|
||||
return width_s >= a && width_s <= b;
|
||||
case WidthCmp::Outside:
|
||||
return width_s < a || width_s > b;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
double EventDetector::feed_pulse(double value, double t) noexcept {
|
||||
const bool up = crossed_up(value);
|
||||
const bool down = crossed_down(value);
|
||||
if (!in_pulse_) {
|
||||
// Which crossing opens a pulse is what `edge` selects: Rising measures the time spent
|
||||
// above the level, Falling the time below, Both every excursion either way.
|
||||
const bool opens = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
if (opens) {
|
||||
in_pulse_ = true;
|
||||
pulse_up_ = up;
|
||||
pulse_start_s_ = t;
|
||||
}
|
||||
return kNoEvent;
|
||||
}
|
||||
const bool closes = pulse_up_ ? down : up;
|
||||
if (!closes) return kNoEvent;
|
||||
const double width = t - pulse_start_s_;
|
||||
if (cond_.edge == TriggerEdge::Both) {
|
||||
// The closing edge of one excursion is the opening edge of the next, so a square wave
|
||||
// measures *every* half-period. Dropping the state here instead would measure every
|
||||
// other one, and the hits would come out half as dense as the waveform.
|
||||
pulse_up_ = !pulse_up_;
|
||||
pulse_start_s_ = t;
|
||||
} else {
|
||||
in_pulse_ = false;
|
||||
}
|
||||
return qualifies(width) ? t : kNoEvent;
|
||||
}
|
||||
|
||||
double EventDetector::feed_window(double value, double t) noexcept {
|
||||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||
const bool inside = value >= lo && value <= hi;
|
||||
if (inside == inside_) return kNoEvent;
|
||||
inside_ = inside;
|
||||
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
||||
// as "out of it". A band of zero width is not a band: `inside` reduces to an exact
|
||||
// float-equality test against the level, so the condition fires on none of an ordinary
|
||||
// signal's samples and on every transition of one that sits exactly at the level -- a
|
||||
// +/-1 square against a band of [1, 1] fired 19 times in a standalone check. Neither
|
||||
// answer is a band, so the panel refuses one.
|
||||
const bool fired = (cond_.edge == TriggerEdge::Rising && inside) ||
|
||||
(cond_.edge == TriggerEdge::Falling && !inside) ||
|
||||
cond_.edge == TriggerEdge::Both;
|
||||
return fired ? t : kNoEvent;
|
||||
}
|
||||
|
||||
double EventDetector::feed_dropout(double value, double t) noexcept {
|
||||
const bool up = crossed_up(value);
|
||||
const bool down = crossed_down(value);
|
||||
const bool crossed = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||
if (crossed) {
|
||||
last_cross_s_ = t;
|
||||
dropped_ = false;
|
||||
return kNoEvent;
|
||||
}
|
||||
// One event per silence, not one per sample of it: without the latch a dead signal would
|
||||
// report an event on every sample after the timeout and fill the search's hit list with
|
||||
// one answer repeated.
|
||||
if (dropped_) return kNoEvent;
|
||||
if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent;
|
||||
dropped_ = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Which way through a level counts. Shared by every condition type: for `Window` it is
|
||||
/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it
|
||||
/// selects which crossings count as the signal still being alive.
|
||||
///
|
||||
/// It lives here rather than in `trigger.hpp` because the buffer search needs the
|
||||
/// evaluator without the engine's arming state.
|
||||
enum class TriggerEdge { Rising, Falling, Both };
|
||||
|
||||
/// What shape in the signal counts as an event.
|
||||
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||
|
||||
/// How a measured pulse width is compared with the condition's bounds. `Less` is the
|
||||
/// glitch trigger: "a pulse shorter than it should be".
|
||||
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||
|
||||
/// Everything that decides whether a sample is an event. No arming, no capture window:
|
||||
/// those belong to `TriggerConfig`.
|
||||
struct EventCondition {
|
||||
TriggerType type = TriggerType::Edge;
|
||||
double level = 0.0;
|
||||
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||
|
||||
bool operator==(const EventCondition& o) const noexcept {
|
||||
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
||||
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
||||
timeout_s == o.timeout_s;
|
||||
}
|
||||
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
||||
};
|
||||
|
||||
/// The event condition's state machine: one sample in, an event time or nothing out.
|
||||
///
|
||||
/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which
|
||||
/// is what lets the buffer search run the *same* evaluator over retained samples and get
|
||||
/// the answers the live trigger would have given.
|
||||
///
|
||||
/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing
|
||||
/// edge, and a dropout is a measured absence. Callers must therefore feed it **every**
|
||||
/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can
|
||||
/// never close.
|
||||
class EventDetector {
|
||||
public:
|
||||
/// What `feed()` returns when the sample is not an event.
|
||||
static constexpr double kNoEvent = std::numeric_limits<double>::quiet_NaN();
|
||||
|
||||
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
||||
/// every packet, and resetting there would restart the machine before any pulse could
|
||||
/// close. A genuinely different condition resets, because its state means nothing.
|
||||
void configure(const EventCondition& c) noexcept;
|
||||
void reset() noexcept;
|
||||
|
||||
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
||||
///
|
||||
/// There is no finite-value check: a NaN sample is benign here (every comparison against
|
||||
/// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good
|
||||
/// value and let the sample *after* the NaN fire a crossing that today cannot happen.
|
||||
double feed(double value, double t) noexcept;
|
||||
|
||||
double last_value() const noexcept { return prev_; }
|
||||
bool have_last() const noexcept { return have_prev_; }
|
||||
|
||||
private:
|
||||
bool crossed_up(double v) const noexcept {
|
||||
return prev_ < cond_.level && v >= cond_.level;
|
||||
}
|
||||
bool crossed_down(double v) const noexcept {
|
||||
return prev_ > cond_.level && v <= cond_.level;
|
||||
}
|
||||
double feed_edge(double value, double t) noexcept;
|
||||
double feed_pulse(double value, double t) noexcept;
|
||||
double feed_window(double value, double t) noexcept;
|
||||
double feed_dropout(double value, double t) noexcept;
|
||||
/// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
|
||||
/// bounds in either order, so a panel cannot make an empty band by typing the larger
|
||||
/// figure into the first spin.
|
||||
bool qualifies(double width_s) const noexcept;
|
||||
|
||||
EventCondition cond_{};
|
||||
bool have_prev_ = false;
|
||||
double prev_ = 0.0;
|
||||
bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen.
|
||||
bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it.
|
||||
double pulse_start_s_ = 0.0;
|
||||
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
||||
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
||||
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -54,6 +54,14 @@ void print_usage(std::FILE* out, const char* argv0) {
|
||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
||||
" added and removed from the layout editor\n"
|
||||
" --capture-packets N store the last N raw datagrams on disk for forensics, in a\n"
|
||||
" ring under the cache directory (1000 slots is ~62.6 MiB);\n"
|
||||
" off unless given, and toggleable from the status bar\n"
|
||||
" --relative-time take the plot's time from the packet counter and the\n"
|
||||
" declared rate, not the stream's time signal; absolute\n"
|
||||
" time is then kept for export only\n"
|
||||
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
||||
" signals from its columns, no socket, no acquisition\n"
|
||||
" -h, --help this text\n",
|
||||
argv0);
|
||||
}
|
||||
@@ -79,6 +87,7 @@ Config parse_args(int argc, char** argv) {
|
||||
c.interface_addr = value_of(argc, argv, i, "--interface");
|
||||
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
||||
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
||||
c.buffer_mb_specified = true;
|
||||
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
||||
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
||||
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
||||
@@ -98,6 +107,15 @@ Config parse_args(int argc, char** argv) {
|
||||
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
||||
}
|
||||
c.plots = static_cast<int>(n);
|
||||
} else if (a == "--capture-packets" || a.rfind("--capture-packets=", 0) == 0) {
|
||||
const long n = std::strtol(value_of(argc, argv, i, "--capture-packets").c_str(), nullptr, 10);
|
||||
if (n < 1 || n > 100000) fail("--capture-packets must be 1..100000");
|
||||
c.capture_packets = static_cast<std::uint32_t>(n);
|
||||
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
||||
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||
} else if (a == "--relative-time") {
|
||||
c.relative_time = true;
|
||||
c.relative_time_specified = true;
|
||||
} else {
|
||||
fail("unknown argument: " + a);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ struct Config {
|
||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||
/// always allocates two identical buffers (spec item 1).
|
||||
double buffer_mb = 200.0;
|
||||
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
|
||||
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
|
||||
/// question as "asked for 200".
|
||||
bool buffer_mb_specified = false;
|
||||
|
||||
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||
/// sized to hold at least this much time (spec: decimation).
|
||||
@@ -35,6 +39,26 @@ struct Config {
|
||||
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
||||
/// starting point: rows are added and removed from the layout editor.
|
||||
int plots = 1;
|
||||
|
||||
/// Take the plot's time from the packet counter and the declared sample rate rather than
|
||||
/// from the stream's own time signal, so arrival and producer-clock jitter cannot move a
|
||||
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
|
||||
bool relative_time = false;
|
||||
|
||||
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
|
||||
/// by the preferences, which the source chooser can reach before this run is planned. A source's
|
||||
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
|
||||
bool relative_time_specified = false;
|
||||
|
||||
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
|
||||
/// 0 means the flag was absent: the capture is off, and the popover button then
|
||||
/// uses the 1000-slot default when the user toggles it.
|
||||
std::uint32_t capture_packets = 0;
|
||||
|
||||
/// `--open FILE`: a `.npy` an earlier run exported, opened for viewing and analysis instead of a
|
||||
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
||||
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||
std::string open_waveform;
|
||||
};
|
||||
|
||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||
|
||||
@@ -32,6 +32,10 @@ struct EventSnapshot {
|
||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||
/// `publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
/// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no
|
||||
/// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives
|
||||
/// the epoch that produced it, so it stores the absolute value outright.
|
||||
std::vector<f64> abs_stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "event_store.hpp"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
|
||||
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
|
||||
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
|
||||
/// "where are my events" into a question about which host you last used.
|
||||
constexpr const char* kEventsSubdir = "/events";
|
||||
|
||||
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
|
||||
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
|
||||
/// the event it collided with.
|
||||
std::string event_stem(double wall_time, std::uint64_t seq) {
|
||||
const std::time_t t = static_cast<std::time_t>(wall_time);
|
||||
std::tm tm{};
|
||||
localtime_r(&t, &tm);
|
||||
char buf[64];
|
||||
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
|
||||
char stem[96];
|
||||
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
|
||||
static_cast<unsigned long long>(seq % 10000ull));
|
||||
return stem;
|
||||
}
|
||||
|
||||
/// `a.npy` -> `a`; anything else -> "".
|
||||
std::string stem_of(const std::string& file, const char* ext) {
|
||||
const std::size_t n = std::strlen(ext);
|
||||
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
|
||||
return file.substr(0, file.size() - n);
|
||||
}
|
||||
|
||||
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
|
||||
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
|
||||
/// is the pixels, so it needs no encoder, and a person can open it with anything.
|
||||
///
|
||||
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
|
||||
/// a glance is one *event* from another, not one signal from another (for which the names are on the
|
||||
/// row above).
|
||||
bool write_preview(const ExportTable& table, const std::string& path, int width = 480, int height = 160) {
|
||||
if (table.empty() || table.columns.empty()) return false;
|
||||
const std::size_t rows = table.rows();
|
||||
const std::size_t strips = table.columns.size();
|
||||
const int strip_h = std::max(6, height / static_cast<int>(strips));
|
||||
const int total_h = strip_h * static_cast<int>(strips);
|
||||
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
|
||||
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
|
||||
|
||||
// One byte per pixel per channel, bottom-up (BMP's own order), padded to a 4-byte row.
|
||||
const int row_bytes = ((width * 3) + 3) & ~3;
|
||||
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
|
||||
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
|
||||
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
|
||||
const double a = std::clamp(alpha, 0.0, 1.0);
|
||||
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
|
||||
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
|
||||
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + (rgb & 0xFF) * a);
|
||||
};
|
||||
// The background: a dark field, so the traces read the way they do on screen.
|
||||
for (int y = 0; y < total_h; ++y) {
|
||||
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
|
||||
}
|
||||
|
||||
for (std::size_t c = 0; c < strips; ++c) {
|
||||
const std::vector<f32>& values = table.columns[c].values;
|
||||
if (values.empty()) continue;
|
||||
double lo = values[0];
|
||||
double hi = values[0];
|
||||
for (f32 v : values) {
|
||||
if (!std::isfinite(v)) continue;
|
||||
lo = std::min(lo, double(v));
|
||||
hi = std::max(hi, double(v));
|
||||
}
|
||||
if (!(hi > lo)) {
|
||||
hi = lo + 1.0;
|
||||
}
|
||||
const int base = static_cast<int>(c) * strip_h;
|
||||
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
|
||||
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
|
||||
double vmin = 1e300;
|
||||
double vmax = -1e300;
|
||||
for (std::size_t i = from; i < to && i < rows; ++i) {
|
||||
const double v = values[i];
|
||||
if (!std::isfinite(v)) continue;
|
||||
vmin = std::min(vmin, v);
|
||||
vmax = std::max(vmax, v);
|
||||
}
|
||||
if (vmin > vmax) continue;
|
||||
const auto to_y = [&](double v) {
|
||||
const double frac = (v - lo) / (hi - lo);
|
||||
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
|
||||
};
|
||||
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
|
||||
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
|
||||
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
|
||||
}
|
||||
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int y = base + strip_h - 1;
|
||||
put(x, y, 0x303840u, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
|
||||
if (!table.time.empty()) {
|
||||
const double t0 = table.time.front();
|
||||
const double t1 = table.time.back();
|
||||
if (t1 > t0) {
|
||||
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
|
||||
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||
if (f == nullptr) return false;
|
||||
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
|
||||
const std::uint32_t file_bytes = 54u + image_bytes;
|
||||
unsigned char header[54] = {};
|
||||
header[0] = 'B';
|
||||
header[1] = 'M';
|
||||
const auto put32 = [&](int at, std::uint32_t v) {
|
||||
header[at] = static_cast<unsigned char>(v & 0xFF);
|
||||
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
|
||||
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
|
||||
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
|
||||
};
|
||||
put32(2, file_bytes);
|
||||
put32(10, 54u);
|
||||
put32(14, 40u);
|
||||
put32(18, std::uint32_t(width));
|
||||
put32(22, std::uint32_t(total_h));
|
||||
header[26] = 1; // planes
|
||||
header[28] = 24; // bits per pixel
|
||||
put32(34, image_bytes);
|
||||
put32(38, 2835u); // 72 dpi, in pixels per metre
|
||||
put32(42, 2835u);
|
||||
std::fwrite(header, 1, sizeof(header), f);
|
||||
std::fwrite(pixels.data(), 1, pixels.size(), f);
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string event_meta_text(const EventRecord& r) {
|
||||
char buf[512];
|
||||
std::string out;
|
||||
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
|
||||
out += buf;
|
||||
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
|
||||
out += buf;
|
||||
return out;
|
||||
}
|
||||
|
||||
bool event_meta_parse(const std::string& text, EventRecord* out) {
|
||||
if (out == nullptr) return false;
|
||||
bool any = false;
|
||||
std::size_t i = 0;
|
||||
while (i < text.size()) {
|
||||
const std::size_t end = text.find('\n', i);
|
||||
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
|
||||
i = end == std::string::npos ? text.size() : end + 1;
|
||||
const std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string value = line.substr(eq + 1);
|
||||
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
|
||||
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
|
||||
const double number = std::atof(value.c_str());
|
||||
if (key == "wall_time") {
|
||||
out->wall_time = number;
|
||||
any = true;
|
||||
} else if (key == "duration_s") {
|
||||
out->duration_s = number;
|
||||
any = true;
|
||||
} else if (key == "pre_s") {
|
||||
out->pre_s = number;
|
||||
any = true;
|
||||
} else if (key == "post_s") {
|
||||
out->post_s = number;
|
||||
any = true;
|
||||
} else if (key == "edge") {
|
||||
out->edge = number;
|
||||
any = true;
|
||||
} else if (key == "rate_hz") {
|
||||
out->rate_hz = number;
|
||||
any = true;
|
||||
} else if (key == "signals") {
|
||||
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||
any = true;
|
||||
} else if (key == "samples") {
|
||||
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||
any = true;
|
||||
} else if (key == "seq") {
|
||||
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
|
||||
|
||||
EventStore::EventStore(std::string source, std::string base_dir) {
|
||||
if (source.empty()) return;
|
||||
std::string safe;
|
||||
for (char c : source) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '_' || c == '-';
|
||||
safe.push_back(ok ? c : '_');
|
||||
}
|
||||
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
|
||||
}
|
||||
|
||||
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
|
||||
if (dir_.empty() || table.empty()) return {};
|
||||
mkdirs(dir_);
|
||||
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
|
||||
meta.seq);
|
||||
const std::string samples = dir_ + "/" + stem + ".npy";
|
||||
std::string error;
|
||||
if (!write_npy(samples, table, error)) {
|
||||
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
|
||||
std::fflush(stderr);
|
||||
return {};
|
||||
}
|
||||
EventRecord record = meta;
|
||||
record.stem = stem;
|
||||
std::string text = event_meta_text(record);
|
||||
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
|
||||
std::fwrite(text.data(), 1, text.size(), f);
|
||||
std::fclose(f);
|
||||
}
|
||||
write_preview(table, dir_ + "/" + stem + ".bmp");
|
||||
prune(keep);
|
||||
return samples;
|
||||
}
|
||||
|
||||
std::vector<EventRecord> EventStore::list() const {
|
||||
std::vector<EventRecord> out;
|
||||
if (dir_.empty()) return out;
|
||||
DIR* d = ::opendir(dir_.c_str());
|
||||
if (d == nullptr) return out;
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = stem_of(e->d_name, ".txt");
|
||||
if (stem.empty()) continue;
|
||||
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
|
||||
if (f == nullptr) continue;
|
||||
std::string text;
|
||||
char buf[512];
|
||||
std::size_t n = 0;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
|
||||
std::fclose(f);
|
||||
EventRecord r;
|
||||
if (!event_meta_parse(text, &r)) continue;
|
||||
r.stem = stem;
|
||||
r.file = dir_ + "/" + stem + ".npy";
|
||||
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
|
||||
struct stat sb {};
|
||||
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
|
||||
r.thumbnail = dir_ + "/" + stem + ".bmp";
|
||||
}
|
||||
out.push_back(std::move(r));
|
||||
}
|
||||
::closedir(d);
|
||||
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
|
||||
// list is about when the event happened.
|
||||
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
|
||||
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
|
||||
return a.seq > b.seq;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
bool EventStore::forget(const std::string& stem) {
|
||||
if (dir_.empty() || stem.empty()) return false;
|
||||
const std::string base = dir_ + "/" + stem;
|
||||
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
|
||||
::unlink((base + ".txt").c_str());
|
||||
::unlink((base + ".bmp").c_str());
|
||||
return gone;
|
||||
}
|
||||
|
||||
void EventStore::prune(std::size_t keep) const {
|
||||
const std::vector<EventRecord> all = list();
|
||||
for (std::size_t i = keep; i < all.size(); ++i) {
|
||||
const std::string base = dir_ + "/" + all[i].stem;
|
||||
::unlink((base + ".npy").c_str());
|
||||
::unlink((base + ".txt").c_str());
|
||||
::unlink((base + ".bmp").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "export.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// What a cached event is, without loading its samples: the sidecar's fields, and what the pane
|
||||
/// shows in one row.
|
||||
struct EventRecord {
|
||||
std::string stem; ///< the file's name without the extension
|
||||
std::string file; ///< `<stem>.npy`, the event's table in the export format
|
||||
std::string thumbnail; ///< `<stem>.bmp`, the preview drawn from the samples; else empty
|
||||
double wall_time = 0.0; ///< CLOCK_REALTIME at the latch, for the row's label
|
||||
double duration_s = 0.0; ///< pre + post
|
||||
double pre_s = 0.0;
|
||||
double post_s = 0.0;
|
||||
double edge = 0.0; ///< the stream time of the event's x = 0
|
||||
double rate_hz = 0.0; ///< the stored sample rate at the latch
|
||||
std::size_t signals = 0; ///< columns in the file
|
||||
std::size_t samples = 0; ///< rows
|
||||
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
|
||||
};
|
||||
|
||||
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
|
||||
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
|
||||
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
|
||||
/// loading the samples, and whatever thumbnail the caller rendered.
|
||||
///
|
||||
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
|
||||
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
|
||||
/// without a window.
|
||||
/// The directory every source's event folder is created under when no preference says otherwise:
|
||||
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
|
||||
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
|
||||
/// is a second thing to get wrong.
|
||||
std::string default_events_dir();
|
||||
|
||||
class EventStore {
|
||||
public:
|
||||
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
|
||||
/// `base_dir` is the directory every source's own folder is created under; empty means
|
||||
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
|
||||
/// home partition and a second disk should not have to choose between events and free space.
|
||||
explicit EventStore(std::string source = {}, std::string base_dir = {});
|
||||
|
||||
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
|
||||
/// preferences are where this becomes the user's number.
|
||||
static constexpr std::size_t kDefaultKeep = 32;
|
||||
|
||||
const std::string& dir() const noexcept { return dir_; }
|
||||
bool empty_dir() const noexcept { return dir_.empty(); }
|
||||
|
||||
/// Writes `table` as one event, with `meta` in the sidecar and a preview drawn beside it. Returns
|
||||
/// the samples' path, or an empty string on failure -- a cache that cannot be written must not stop
|
||||
/// the scope.
|
||||
///
|
||||
/// The preview is drawn from the *data* -- min/max per pixel column, one strip per signal -- and 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 (`gtk_snapshot_free_to_node()` returns NULL). Drawing it
|
||||
/// here also keeps this class GTK-free, which is what makes it testable without a window.
|
||||
std::string save(const ExportTable& table, const EventRecord& meta,
|
||||
std::size_t keep = kDefaultKeep);
|
||||
|
||||
/// The cached events, newest first: the sidecars, never the sample files, so a listing is cheap
|
||||
/// however large the captures are.
|
||||
std::vector<EventRecord> list() const;
|
||||
|
||||
/// Deletes one event's samples, sidecar and thumbnail.
|
||||
bool forget(const std::string& stem);
|
||||
|
||||
/// Deletes the oldest events beyond `keep`.
|
||||
void prune(std::size_t keep) const;
|
||||
|
||||
private:
|
||||
std::string dir_;
|
||||
};
|
||||
|
||||
/// The sidecar's text, and back. One `key = value` per line, the project's own TOML subset, so a
|
||||
/// human can read the cache with `cat` -- which is the point of a forensic directory.
|
||||
std::string event_meta_text(const EventRecord& record);
|
||||
bool event_meta_parse(const std::string& text, EventRecord* out);
|
||||
|
||||
} // namespace udpscope
|
||||
+26
-2
@@ -127,7 +127,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
table.abs_seconds.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = st[first + i];
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
table.abs_seconds[i] = snap->abs_stamps.empty()
|
||||
? abs_base + table.time[i]
|
||||
: snap->abs_stamps[first + i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -159,9 +161,16 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||
std::vector<f64> abs_stamps;
|
||||
if (const plot::data::RingBuffer<f64>* abs_ring = store.abs_time(); abs_ring != nullptr) {
|
||||
abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs_ring->copy_from(first, n, abs_stamps.data());
|
||||
}
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.time[i] = stamps[i] - s0;
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
table.abs_seconds[i] = abs_stamps.empty()
|
||||
? abs_base + table.time[i]
|
||||
: model.epoch() + abs_stamps[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -173,6 +182,21 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
return table;
|
||||
}
|
||||
|
||||
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
|
||||
ExportTable table;
|
||||
table.time = snapshot.stamps;
|
||||
table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps;
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
const std::vector<ChannelInfo>& channels = model.channels();
|
||||
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
||||
ExportTable::Column column;
|
||||
column.name = channels[c].name;
|
||||
column.values = snapshot.values[c];
|
||||
table.columns.push_back(std::move(column));
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
||||
if (table.empty()) {
|
||||
error = "nothing to export: no signal is plotted in the visible range";
|
||||
|
||||
@@ -54,6 +54,12 @@ struct ExportTable {
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
|
||||
/// acquisition, every channel the event holds, independent of which traces a row happens to show
|
||||
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
|
||||
/// columns come out in, so `--open` restores the same signals.
|
||||
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
|
||||
+13
-2
@@ -6,8 +6,19 @@ interface. Pass it on the command line, or let udpscope ask.
|
||||
<b>How to reach it</b>
|
||||
|
||||
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
||||
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
|
||||
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
|
||||
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
|
||||
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
|
||||
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
|
||||
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
|
||||
opens the same form on that configuration, pre-filled, and changes to its name or address move it
|
||||
(the old file is deleted).
|
||||
|
||||
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
|
||||
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
|
||||
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
|
||||
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
|
||||
starvation notice — and every instrument works on it: cursors, measurements, math channels and
|
||||
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
|
||||
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
@@ -13,6 +13,9 @@ the overflow menu opens the editor where the unit and the calibration live.
|
||||
|
||||
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
||||
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
||||
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
|
||||
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
|
||||
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
|
||||
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
||||
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
||||
for a second after one of those is the ring refilling, not a fault.
|
||||
@@ -22,6 +25,9 @@ the overflow menu opens the editor where the unit and the calibration live.
|
||||
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
||||
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
||||
gets a grey chip rather than none, so the panel's rows keep their height.
|
||||
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
|
||||
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
|
||||
signal's ⋯ menu gives the palette entry back.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ corner leaves it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
|
||||
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
|
||||
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
|
||||
off a row without going through the layout editor.
|
||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
||||
traces already on a row the drop is refused.
|
||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
||||
|
||||
@@ -6,7 +6,9 @@ pointer is over, and because the rows share one time axis, an x change moves all
|
||||
<b>How to reach it</b>
|
||||
|
||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
||||
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
||||
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
|
||||
<b>Alt+drag</b> on values only.
|
||||
The area being chosen is framed while you drag it, so you can see what the release will apply.
|
||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
@@ -35,6 +37,19 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
||||
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
|
||||
wandered does not drift away from its axis as you zoom.
|
||||
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
||||
time</i>: each column is a single vertical line from the lowest to the highest value that falls
|
||||
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
|
||||
every column at slightly different sub-pixel positions, and how they overlap beats against the
|
||||
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
|
||||
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
|
||||
line segments a frame instead of a couple of hundred thousand.
|
||||
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
|
||||
where the signal spent its time, faint where it only passed through. A column's highest and
|
||||
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
|
||||
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
|
||||
the edges between them faint. It is read from a finer division of each column rather than from
|
||||
the line that is drawn, and it costs about a millisecond a frame.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
+44
-3
@@ -1,16 +1,40 @@
|
||||
<b>Trigger</b>
|
||||
|
||||
The trigger watches one signal for an edge through a level and captures a window around it.
|
||||
The trigger watches one signal for a condition and captures a window around it. The
|
||||
<b>Shape</b> dropdown chooses what counts as an event; the arming, hold-off, pre/post window
|
||||
and Normal/Single mode are the same for all four shapes.
|
||||
The captured window is frozen on screen until you re-arm.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
||||
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
|
||||
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
||||
the shape, the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger
|
||||
share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
||||
<b>Search the buffer</b> is at the foot of the same panel.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
|
||||
(<tt>~/.cache/udpscope/events/<source>/</tt>) as a <tt>.npy</tt> of the event, a
|
||||
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
|
||||
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
|
||||
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
|
||||
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
|
||||
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
|
||||
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
|
||||
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
|
||||
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
|
||||
stream; the status bar names the file until you do.
|
||||
Select two or more events in the list and the two buttons under the header act on them:
|
||||
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
|
||||
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
|
||||
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
|
||||
the trace is brighter. Your own traces stay where they are in both. They need events with the same
|
||||
signals and the same length. A single click selects, a <i>double</i> click replays.
|
||||
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
|
||||
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
|
||||
with it off the history button goes with it.
|
||||
|
||||
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
||||
can never fire.
|
||||
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
||||
@@ -27,6 +51,23 @@ the edge (rising, falling, either), the mode (Normal or Single) and the pre-trig
|
||||
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
||||
A spike narrower than one decimation group is filtered away rather than triggering on data
|
||||
the buffer does not contain.
|
||||
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
|
||||
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
|
||||
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
|
||||
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
|
||||
panel keeps the two levels at least one increment apart, because a zero-width band is not a
|
||||
band: the condition becomes 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.
|
||||
<b>Dropout</b> —
|
||||
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
|
||||
crossing returns.
|
||||
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
|
||||
a pulse width to a window trigger.
|
||||
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
|
||||
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
|
||||
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
|
||||
are more the panel says how many and suggests zooming in. The search uses the trigger's own
|
||||
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ set, with <b>Save</b> and <b>Delete</b>.
|
||||
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
|
||||
property of the signal and outlives the window; a position inside the window does not. That is
|
||||
what stops the rulers blinking off at a mode change.
|
||||
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
|
||||
the window's peak to peak either side of the share, and an edge is counted only once the signal
|
||||
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
|
||||
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
|
||||
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
|
||||
of the waveform's own size.
|
||||
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
|
||||
usable: a fixed band there would sit above every sample and the measurement would read nothing.
|
||||
A share near an extreme still leaves the band little room, so it rejects less — which is one
|
||||
reason period and frequency offer no share to set.
|
||||
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
||||
invented number. Frequency over a window holding one edge is the usual case.
|
||||
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
||||
@@ -34,6 +45,12 @@ set, with <b>Save</b> and <b>Delete</b>.
|
||||
small value is <tt>0.00</tt>.
|
||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||
window. The values are still exact: a bucket's sum is a sum.
|
||||
• <b>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
|
||||
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
|
||||
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
|
||||
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
|
||||
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
|
||||
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
|
||||
+19
-9
@@ -5,15 +5,24 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one
|
||||
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas, click an output port then an input port to wire them, drag a box to move it. The
|
||||
orange dot marks the output node.
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
|
||||
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
|
||||
node arrives as the operation you asked for.
|
||||
|
||||
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
|
||||
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
|
||||
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
|
||||
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
|
||||
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
|
||||
dot marks the output node; click <i>set as output</i> to move it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
||||
cycle is impossible by construction rather than rejected by a check.
|
||||
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
|
||||
unrepresentable: the connection is refused rather than created, and the line beneath the form
|
||||
says why — a loop, or a node asked to read itself.
|
||||
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
|
||||
measured, exported, and used as a source for another math channel.
|
||||
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
|
||||
@@ -40,10 +49,11 @@ and the last node added becomes the output -- but a graph whose output is left o
|
||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||
have no effect. <i>Set as output</i> moves the dot.
|
||||
|
||||
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
|
||||
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||
that reads NaN is harder to diagnose than one that would not save.
|
||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
||||
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
|
||||
would not save.
|
||||
|
||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
|
||||
+41
-15
@@ -1,34 +1,60 @@
|
||||
<b>Sessions and settings</b>
|
||||
|
||||
udpscope stores one session file per source, so reopening a source gives you the scope you
|
||||
closed. There is no global preferences file: the session <i>is</i> the settings.
|
||||
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
|
||||
you the scope you closed, and one source can hold several arrangements for different experiments.
|
||||
There is no global preferences file: the session <i>is</i> the settings.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
||||
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
||||
unset. Each file is named <tt><host>_<port>.toml</tt>.
|
||||
unset. Each source has a directory <tt><host>_<port>/</tt> holding one
|
||||
<tt><name>.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
|
||||
|
||||
<b>Configurations</b>
|
||||
|
||||
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
|
||||
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
|
||||
the current arrangement under a new one. Opening a configuration saves the current session first,
|
||||
so switching never loses what it switches from.
|
||||
|
||||
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
|
||||
note and its settings, so two arrangements of one source are told apart before they open.
|
||||
|
||||
A configuration also carries the settings that can only be decided when the buffers are
|
||||
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
|
||||
ring size. Opening a configuration from the chooser applies all three; switching inside a running
|
||||
app applies the rest and says in the status line that these take effect on the next start.
|
||||
|
||||
The chooser's form sets the first two for a new host, and its pencil button edits them on an
|
||||
existing configuration — along with the name and the note. Renaming a configuration, or editing
|
||||
the address it points at, moves it: the old file is removed rather than left behind.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• What a session persists: the time window, the rows and each row's slot signals, the
|
||||
calibration, which signals are enabled, the math channels with their graphs, and the
|
||||
measurements pane.
|
||||
• What a configuration persists: the time window, the rows and each row's slot signals, the
|
||||
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
|
||||
the measurements pane — and the startup settings: buffer size, relative time and the packet
|
||||
capture's ring size.
|
||||
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
||||
• A file written by a run against a source whose streamer was down would name no signals at all,
|
||||
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
||||
configured nothing.
|
||||
configured nothing. (The chooser hides such a file for the same reason.)
|
||||
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
||||
carry them. A file from an older build has neither key and loads as unicast on the default
|
||||
route.
|
||||
• The startup chooser lists only files that name at least one signal, which is what keeps it the
|
||||
sources you configured rather than every port you have ever tested against.
|
||||
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
|
||||
exactly that.
|
||||
• The chooser lists only configurations that name at least one signal, which is what keeps it the
|
||||
sessions you configured rather than every port you have ever tested against.
|
||||
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
|
||||
dialog's do exactly that.
|
||||
• A session from before configurations existed is imported once as the configuration
|
||||
<tt>default</tt>, and the old file is kept beside the directory as
|
||||
<tt><host>_<port>.toml.imported</tt>.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
|
||||
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
|
||||
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
|
||||
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
|
||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
|
||||
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
|
||||
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
|
||||
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
|
||||
already acquired.
|
||||
+82
-118
@@ -16,12 +16,34 @@ namespace {
|
||||
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||
/// One pointer per window, file-scope because there is one application.
|
||||
///
|
||||
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||
/// `adw_dialog_close()`.
|
||||
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
|
||||
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
|
||||
/// `::closed` and closes with `adw_dialog_close()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
|
||||
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
|
||||
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
|
||||
/// gone.
|
||||
GtkBuilder* g_help_ui = nullptr;
|
||||
GtkWidget* g_help_body = nullptr;
|
||||
GtkWidget* g_help_shots = nullptr;
|
||||
AdwDialog* g_about = nullptr;
|
||||
AdwDialog* g_shortcuts = nullptr;
|
||||
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
|
||||
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||
/// builder is replaced on the next opening rather than reused.
|
||||
GtkWidget* g_shortcuts = nullptr;
|
||||
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||
|
||||
/// The help window's own close handler, rather than the shared one: this window also owns the label a
|
||||
/// page is written into and the column its screenshots are built in, and a page switch that ran after
|
||||
/// the window was gone would read both.
|
||||
gboolean on_help_close(GtkWindow* window, gpointer) {
|
||||
(void)window;
|
||||
g_help = nullptr;
|
||||
g_help_body = nullptr;
|
||||
g_help_shots = nullptr;
|
||||
return FALSE; // let the default handler destroy it
|
||||
}
|
||||
|
||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
(void)window;
|
||||
@@ -103,20 +125,18 @@ std::string load_page(const HelpPage& page, bool* ok) {
|
||||
return body;
|
||||
}
|
||||
|
||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
|
||||
if (row == nullptr) return;
|
||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
|
||||
if (row == nullptr || g_help_body == nullptr) return;
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
||||
if (idx < 0 || idx >= kPageCount) return;
|
||||
bool ok = false;
|
||||
const std::string body = load_page(kPages[idx], &ok);
|
||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||
GtkWidget* label = GTK_WIDGET(body_label);
|
||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
|
||||
// being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
|
||||
// rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
|
||||
// not the last one's.
|
||||
GtkWidget* shots = g_help_shots;
|
||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||
gtk_box_remove(GTK_BOX(shots), old);
|
||||
@@ -196,47 +216,31 @@ void show_help(GtkWindow* parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
GtkWidget* window = gtk_window_new();
|
||||
g_help = window;
|
||||
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
|
||||
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
|
||||
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
|
||||
// fetched. This is where the previous one goes, and it is safe because that window has been closed
|
||||
// for as long as it takes someone to ask for help again.
|
||||
g_clear_object(&g_help_ui);
|
||||
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
|
||||
if (g_help_ui == nullptr) return;
|
||||
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
|
||||
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
|
||||
GObject* body = gtk_builder_get_object(g_help_ui, "body");
|
||||
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
|
||||
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
|
||||
// null widget on the first page switch.
|
||||
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
|
||||
g_help = GTK_WIDGET(window);
|
||||
g_help_body = GTK_WIDGET(body);
|
||||
g_help_shots = GTK_WIDGET(shots);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
|
||||
|
||||
// One label, width-locked by the scrolled window, holding one page's markup. The
|
||||
// sidebar swaps the markup rather than rebuilding anything.
|
||||
GtkWidget* body = gtk_label_new(nullptr);
|
||||
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
|
||||
gtk_widget_set_margin_start(body, 18);
|
||||
gtk_widget_set_margin_end(body, 18);
|
||||
gtk_widget_set_margin_top(body, 14);
|
||||
gtk_widget_set_margin_bottom(body, 14);
|
||||
|
||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_start(shot_box, 18);
|
||||
gtk_widget_set_margin_end(shot_box, 18);
|
||||
gtk_widget_set_margin_top(shot_box, 14);
|
||||
gtk_widget_set_visible(shot_box, false);
|
||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||
gtk_box_append(GTK_BOX(page_box), body);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||
|
||||
GtkWidget* list = gtk_list_box_new();
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
|
||||
// The rows are the pages, which is data and not template: a heading row per section, then one row per
|
||||
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
|
||||
// what a GtkListView with one item factory can draw.
|
||||
const char* current_section = nullptr;
|
||||
for (int i = 0; i < kPageCount; ++i) {
|
||||
if (current_section == nullptr ||
|
||||
std::string(current_section) != kPages[i].section) {
|
||||
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
|
||||
current_section = kPages[i].section;
|
||||
GtkWidget* heading = gtk_label_new(nullptr);
|
||||
char markup[128];
|
||||
@@ -263,89 +267,49 @@ void show_help(GtkWindow* parent) {
|
||||
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||
}
|
||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
|
||||
|
||||
GtkWidget* list_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
|
||||
gtk_widget_set_size_request(list_scroll, 220, -1);
|
||||
|
||||
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
gtk_box_append(GTK_BOX(split), list_scroll);
|
||||
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||
gtk_box_append(GTK_BOX(split), body_scroll);
|
||||
gtk_window_set_child(GTK_WINDOW(window), split);
|
||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
|
||||
|
||||
// Select the first page so the window never opens blank.
|
||||
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
||||
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
||||
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
|
||||
gtk_window_present(GTK_WINDOW(g_help));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
|
||||
/// string differs from the last one. `*current` is the section being filled, `*title` the
|
||||
/// name it carries; both are owned by the caller so the two tables share one run.
|
||||
///
|
||||
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
|
||||
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
|
||||
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
|
||||
/// "Wheel" costs a parse warning and draws nothing.
|
||||
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
|
||||
const char** title, const char* section, const char* accel,
|
||||
const char* subtitle, const char* description) {
|
||||
if (*current == nullptr || std::string(*title) != section) {
|
||||
*current = adw_shortcuts_section_new(section);
|
||||
*title = section;
|
||||
adw_shortcuts_dialog_add(dialog, *current);
|
||||
}
|
||||
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
|
||||
if (subtitle != nullptr && *subtitle != '\0') {
|
||||
adw_shortcuts_item_set_subtitle(item, subtitle);
|
||||
}
|
||||
adw_shortcuts_section_add(*current, item);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void show_shortcuts(GtkWindow* parent) {
|
||||
if (g_shortcuts != nullptr) {
|
||||
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
return;
|
||||
}
|
||||
|
||||
AdwDialog* dialog = adw_shortcuts_dialog_new();
|
||||
g_shortcuts = dialog;
|
||||
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
|
||||
|
||||
AdwShortcutsSection* section = nullptr;
|
||||
const char* section_title = nullptr;
|
||||
int accel_count = 0;
|
||||
const Accel* accels = app_accels(&accel_count);
|
||||
for (int i = 0; i < accel_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
accels[i].section, accels[i].accel, "", accels[i].description);
|
||||
}
|
||||
int gesture_count = 0;
|
||||
const Gesture* gestures = plot_gestures(&gesture_count);
|
||||
for (int i = 0; i < gesture_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
|
||||
gestures[i].description);
|
||||
}
|
||||
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
||||
// section per page, with the sidebar and the search box drawn by the window itself.
|
||||
//
|
||||
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
|
||||
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
|
||||
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
|
||||
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
|
||||
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
|
||||
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
|
||||
//
|
||||
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
||||
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
|
||||
g_clear_object(&g_shortcuts_ui);
|
||||
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
||||
if (g_shortcuts_ui == nullptr) return;
|
||||
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
|
||||
if (overlay == nullptr) return;
|
||||
g_shortcuts = GTK_WIDGET(overlay);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
|
||||
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
void close_help_windows() {
|
||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- The event history: a list of what was latched. A frame with three rows in it, the
|
||||
last one short, so it reads as a list that ends rather than one that continues. Every
|
||||
coordinate is a whole pixel of the 16px cell, and every subpath is a clockwise filled
|
||||
`path`: a stroke, a line or a polyline keeps the colour written here and stays dark on
|
||||
a dark theme, and the frame's hole needs `evenodd` because the rows sit inside it.
|
||||
(No double hyphen anywhere in a comment here: xmllint, which preprocess= runs it
|
||||
through, rejects one and fails the whole build.) -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" fill-rule="evenodd" d="M0 1 H16 V15 H0 Z
|
||||
M2 3 H14 V13 H2 Z"/>
|
||||
<path fill="#2e3436" d="M4 4 H12 V6 H4 Z
|
||||
M4 7 H12 V9 H4 Z
|
||||
M4 10 H9 V12 H4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- A boundary that can be dragged: a vertical double-headed arrow. It says *which* edge the handle
|
||||
moves, which a plain grip does not. Every coordinate is a whole pixel of the 16px cell, every
|
||||
subpath is a filled `path` wound the same way (a stroke would keep this colour on a dark theme),
|
||||
and the two heads meet the shaft exactly, at x 5..11 and x 7..9. -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M7 4 H9 V12 H7 Z
|
||||
M8 1 L11 5 H5 Z
|
||||
M8 15 L5 11 H11 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
@@ -21,6 +21,8 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||
</gresource>
|
||||
|
||||
+267
-43
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
|
||||
return out.empty() ? "_" : out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
|
||||
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
|
||||
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
|
||||
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
|
||||
void mkdirs(const std::string& path) {
|
||||
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
||||
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
||||
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string toml_escape(const std::string& s) {
|
||||
std::string out = "\"";
|
||||
for (char c : s) {
|
||||
@@ -114,56 +122,223 @@ std::string config_dir() {
|
||||
return std::string(home) + "/.config/udpscope";
|
||||
}
|
||||
|
||||
std::string cache_dir() {
|
||||
const char* xdg = std::getenv("XDG_CACHE_HOME");
|
||||
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-cache";
|
||||
return std::string(home) + "/.cache/udpscope";
|
||||
}
|
||||
|
||||
std::string layout_path(const std::string& host, std::uint16_t port) {
|
||||
const std::string dir = config_dir();
|
||||
mkdirs(dir);
|
||||
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
||||
}
|
||||
|
||||
std::vector<KnownSource> known_sources() {
|
||||
std::vector<KnownSource> out;
|
||||
const std::string dir = config_dir();
|
||||
std::string config_dir_for(const std::string& host, std::uint16_t port) {
|
||||
return config_dir() + "/" + sanitize(host) + "_" + std::to_string(port);
|
||||
}
|
||||
|
||||
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||
const std::string& name) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
mkdirs(dir);
|
||||
return dir + "/" + sanitize(name) + ".toml";
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool file_exists(const std::string& path) {
|
||||
struct stat sb {};
|
||||
return ::stat(path.c_str(), &sb) == 0;
|
||||
}
|
||||
|
||||
/// A session written before configurations existed is the configuration named `default`: copied
|
||||
/// into the per-source directory, remembered as active, and the old file renamed out of the way
|
||||
/// rather than deleted, so the import can be undone by hand. Runs before every read and write of
|
||||
/// the per-source directory, so a machine that upgrades keeps its session.
|
||||
void import_legacy(const std::string& host, std::uint16_t port) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
if (file_exists(dir)) return;
|
||||
const std::string legacy = layout_path(host, port);
|
||||
if (!file_exists(legacy)) return;
|
||||
mkdirs(dir);
|
||||
FILE* in = std::fopen(legacy.c_str(), "rb");
|
||||
if (in == nullptr) return;
|
||||
const std::string target = dir + "/default.toml";
|
||||
FILE* out = std::fopen(target.c_str(), "wb");
|
||||
if (out == nullptr) {
|
||||
std::fclose(in);
|
||||
return;
|
||||
}
|
||||
char buf[4096];
|
||||
std::size_t n = 0;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), in)) > 0) std::fwrite(buf, 1, n, out);
|
||||
std::fclose(in);
|
||||
std::fclose(out);
|
||||
FILE* active = std::fopen((dir + "/active").c_str(), "wb");
|
||||
if (active != nullptr) {
|
||||
std::fputs("default\n", active);
|
||||
std::fclose(active);
|
||||
}
|
||||
::rename(legacy.c_str(), (legacy + ".imported").c_str());
|
||||
}
|
||||
|
||||
/// `a.toml` -> `a`; anything else -> "".
|
||||
std::string toml_stem(const std::string& file) {
|
||||
if (file.size() <= 5 || file.compare(file.size() - 5, 5, ".toml") != 0) return {};
|
||||
return file.substr(0, file.size() - 5);
|
||||
}
|
||||
|
||||
/// `host_port` -> {host, port}, or false. The *last* '_' is the split, because `sanitize()`
|
||||
/// replaces anything unsafe in a host with one.
|
||||
bool split_stem(const std::string& stem, std::string* host, std::uint16_t* port) {
|
||||
const std::size_t cut = stem.rfind('_');
|
||||
if (cut == std::string::npos || cut + 1 == stem.size()) return false;
|
||||
char* end = nullptr;
|
||||
const long p = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||
if (end == nullptr || *end != '\0' || p <= 0 || p > 65535) return false;
|
||||
*host = stem.substr(0, cut);
|
||||
*port = static_cast<std::uint16_t>(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The configurations a source directory holds, newest first, by file name.
|
||||
std::vector<std::string> names_in_dir(const std::string& dir) {
|
||||
std::vector<std::pair<std::int64_t, std::string>> found;
|
||||
DIR* d = ::opendir(dir.c_str());
|
||||
if (d == nullptr) return {};
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = toml_stem(e->d_name);
|
||||
if (stem.empty()) continue;
|
||||
struct stat sb {};
|
||||
const std::string path = dir + "/" + e->d_name;
|
||||
std::int64_t mtime = 0;
|
||||
if (::stat(path.c_str(), &sb) == 0) mtime = static_cast<std::int64_t>(sb.st_mtime);
|
||||
found.emplace_back(mtime, stem);
|
||||
}
|
||||
::closedir(d);
|
||||
std::stable_sort(found.begin(), found.end(),
|
||||
[](const auto& a, const auto& b) { return a.first > b.first; });
|
||||
std::vector<std::string> out;
|
||||
out.reserve(found.size());
|
||||
for (auto& f : found) out.push_back(std::move(f.second));
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port) {
|
||||
import_legacy(host, port);
|
||||
return names_in_dir(config_dir_for(host, port));
|
||||
}
|
||||
|
||||
std::string active_configuration(const std::string& host, std::uint16_t port) {
|
||||
const std::vector<std::string> names = configuration_names(host, port);
|
||||
if (names.empty()) return "default";
|
||||
FILE* f = std::fopen((config_dir_for(host, port) + "/active").c_str(), "rb");
|
||||
if (f != nullptr) {
|
||||
char buf[512];
|
||||
const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = '\0';
|
||||
const std::string want = trim(std::string(buf));
|
||||
if (std::find(names.begin(), names.end(), want) != names.end()) return want;
|
||||
}
|
||||
// Newest, which is the one just saved when nothing wrote an `active`.
|
||||
return names.front();
|
||||
}
|
||||
|
||||
bool set_active_configuration(const std::string& host, std::uint16_t port,
|
||||
const std::string& name) {
|
||||
const std::string dir = config_dir_for(host, port);
|
||||
mkdirs(dir);
|
||||
FILE* f = std::fopen((dir + "/active").c_str(), "wb");
|
||||
if (f == nullptr) return false;
|
||||
std::fputs(sanitize(name).c_str(), f);
|
||||
std::fputs("\n", f);
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name) {
|
||||
const std::string path = configuration_path(host, port, name);
|
||||
const bool gone = std::remove(path.c_str()) == 0;
|
||||
const std::string active = active_configuration(host, port);
|
||||
if (gone && sanitize(name) == sanitize(active)) {
|
||||
std::remove((config_dir_for(host, port) + "/active").c_str());
|
||||
}
|
||||
return gone;
|
||||
}
|
||||
|
||||
std::vector<KnownSource> known_sources() {
|
||||
const std::string root = config_dir();
|
||||
|
||||
// A legacy single-file session is imported first, so the listing below has one shape: the
|
||||
// per-source directory and its configuration files.
|
||||
if (DIR* d = ::opendir(root.c_str())) {
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string stem = toml_stem(e->d_name);
|
||||
if (stem.empty()) continue;
|
||||
std::string host;
|
||||
std::uint16_t port = 0;
|
||||
if (!split_stem(stem, &host, &port)) continue;
|
||||
// The scheme `layout_path()` writes, and nothing else: a stray `.toml` here is not ours.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||
import_legacy(host, port);
|
||||
}
|
||||
::closedir(d);
|
||||
}
|
||||
|
||||
std::vector<KnownSource> out;
|
||||
DIR* d = ::opendir(root.c_str());
|
||||
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||||
if (d == nullptr) return out;
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string name = e->d_name;
|
||||
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
||||
const std::string stem = name.substr(0, name.size() - 5);
|
||||
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
||||
// may hold them too and a first-match split would return a host cut in half.
|
||||
const std::size_t cut = stem.rfind('_');
|
||||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||||
char* end = nullptr;
|
||||
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
|
||||
const std::string host = stem.substr(0, cut);
|
||||
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
|
||||
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
|
||||
// any host that already came back out of a filename, so this is also true for hosts whose names
|
||||
// contain `_` from the sanitizer.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||
|
||||
LayoutState st;
|
||||
const std::string path = dir + "/" + name;
|
||||
if (!load_layout(path, st)) continue;
|
||||
|
||||
KnownSource s;
|
||||
s.host = host;
|
||||
s.port = static_cast<std::uint16_t>(port);
|
||||
s.multicast = st.multicast;
|
||||
s.interface_addr = st.interface_addr;
|
||||
s.rows = static_cast<int>(st.rows.size());
|
||||
for (const LayoutState::Row& row : st.rows) {
|
||||
for (const std::string& sig : row.signals) {
|
||||
if (!sig.empty()) ++s.signals;
|
||||
}
|
||||
}
|
||||
if (s.signals == 0) continue; // see the header: a session nobody configured
|
||||
|
||||
const std::string dir = root + "/" + e->d_name;
|
||||
struct stat sb {};
|
||||
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
||||
out.push_back(std::move(s));
|
||||
if (::stat(dir.c_str(), &sb) != 0 || !S_ISDIR(sb.st_mode)) continue;
|
||||
std::string host;
|
||||
std::uint16_t port = 0;
|
||||
if (!split_stem(e->d_name, &host, &port)) continue;
|
||||
// The name must be one `config_dir_for()` could have written, or `forget_source()` would walk
|
||||
// out of the directory the row came from.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != e->d_name) continue;
|
||||
|
||||
// One row per *configuration*: the same source may hold several named sessions, which is the
|
||||
// point of the feature, and the chooser is where they are told apart.
|
||||
for (const std::string& name : names_in_dir(dir)) {
|
||||
const std::string path = dir + "/" + name + ".toml";
|
||||
LayoutState st;
|
||||
if (!load_layout(path, st)) continue;
|
||||
KnownSource s;
|
||||
s.host = host;
|
||||
s.port = port;
|
||||
s.config = name;
|
||||
s.comment = st.comment;
|
||||
s.buffer_mb = st.buffer_mb;
|
||||
s.relative_time = st.relative_time;
|
||||
s.capture_packets = st.capture_packets;
|
||||
s.multicast = st.multicast;
|
||||
s.interface_addr = st.interface_addr;
|
||||
s.rows = static_cast<int>(st.rows.size());
|
||||
for (const LayoutState::Row& row : st.rows) {
|
||||
for (const std::string& sig : row.signals) {
|
||||
if (!sig.empty()) ++s.signals;
|
||||
}
|
||||
}
|
||||
// A configuration nobody configured is not worth a row: a development machine accumulates one
|
||||
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
|
||||
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
|
||||
// set up from the chooser's form, and the settings are the whole point of editing one.
|
||||
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
|
||||
st.relative_time || st.capture_packets > 0;
|
||||
if (!configured) continue;
|
||||
struct stat fb {};
|
||||
s.mtime = (::stat(path.c_str(), &fb) == 0) ? static_cast<std::int64_t>(fb.st_mtime) : 0;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
::closedir(d);
|
||||
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||||
@@ -174,10 +349,10 @@ std::vector<KnownSource> known_sources() {
|
||||
}
|
||||
|
||||
bool forget_source(const KnownSource& src) {
|
||||
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
||||
// Through `configuration_path()` rather than by rebuilding the name here: one expression of the
|
||||
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||||
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||||
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
||||
return forget_configuration(src.host, src.port, src.config);
|
||||
}
|
||||
|
||||
std::string math_node_text(const MathNode& node) {
|
||||
@@ -239,13 +414,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
st.window_s = 0.0;
|
||||
st.rows.clear();
|
||||
st.calibration.clear();
|
||||
st.colors.clear();
|
||||
st.enabled.clear();
|
||||
st.math.clear();
|
||||
st.measures.clear();
|
||||
|
||||
std::string line;
|
||||
// What the last `[[...]]` header opened, so keys land in the right record.
|
||||
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
|
||||
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
|
||||
int plot_count = 0;
|
||||
bool plot_section_seen = false;
|
||||
while (std::getline(in, line)) {
|
||||
@@ -259,6 +435,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} else if (t == "[[calibration]]") {
|
||||
section = Section::Calibration;
|
||||
st.calibration.emplace_back();
|
||||
} else if (t == "[[color]]") {
|
||||
section = Section::Color;
|
||||
st.colors.emplace_back();
|
||||
} else if (t == "[[math]]") {
|
||||
section = Section::Math;
|
||||
st.math.emplace_back();
|
||||
@@ -305,10 +484,26 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
plot_count = std::max(0, std::stoi(value));
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "measure_stats") {
|
||||
st.measure_stats = value == "true";
|
||||
} else if (key == "multicast") {
|
||||
st.multicast = strip_quote(value);
|
||||
} else if (key == "interface") {
|
||||
st.interface_addr = strip_quote(value);
|
||||
} else if (key == "comment") {
|
||||
st.comment = strip_quote(value);
|
||||
} else if (key == "buffer_mb") {
|
||||
try {
|
||||
st.buffer_mb = std::stod(value);
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "relative_time") {
|
||||
st.relative_time = value == "true";
|
||||
} else if (key == "capture_packets") {
|
||||
try {
|
||||
st.capture_packets = static_cast<std::uint32_t>(std::max(0, std::stoi(value)));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||
if (key == "signals") {
|
||||
@@ -317,6 +512,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
parse_string_array(value.substr(1, value.size() - 2), names);
|
||||
}
|
||||
st.rows.back().signals = names;
|
||||
} else if (key == "weight") {
|
||||
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
||||
}
|
||||
} else if (section == Section::Math && !st.math.empty()) {
|
||||
MathChannel& m = st.math.back();
|
||||
@@ -378,6 +575,23 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Color && !st.colors.empty()) {
|
||||
if (key == "name") {
|
||||
st.colors.back().name = strip_quote(value);
|
||||
} else if (key == "rgb") {
|
||||
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
|
||||
// would be the one number in this file nobody could check by eye. Anything unparseable
|
||||
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
|
||||
const std::string hex = strip_quote(value);
|
||||
const std::size_t at = hex.find('#');
|
||||
try {
|
||||
st.colors.back().rgb =
|
||||
static_cast<std::uint32_t>(std::stoul(
|
||||
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
|
||||
0x00FFFFFFu;
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!plot_section_seen && plot_count > 0) {
|
||||
@@ -410,8 +624,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
}
|
||||
o << "]\n";
|
||||
}
|
||||
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
||||
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
||||
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
|
||||
for (const auto& row : state.rows) {
|
||||
o << "\n[[plot]]\nsignals = [";
|
||||
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
|
||||
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
||||
if (i != 0) o << ", ";
|
||||
o << toml_escape(row.signals[i]);
|
||||
@@ -422,6 +641,11 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
||||
<< "\noffset = " << cal.offset << "\n";
|
||||
}
|
||||
for (const auto& color : state.colors) {
|
||||
char rgb[16];
|
||||
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
|
||||
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
|
||||
}
|
||||
for (const MathChannel& m : state.math) {
|
||||
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
|
||||
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
|
||||
|
||||
+77
-4
@@ -20,6 +20,9 @@ struct LayoutState {
|
||||
struct Row {
|
||||
/// One entry per slot; an empty string is an empty slot.
|
||||
std::vector<std::string> signals;
|
||||
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
|
||||
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
|
||||
double weight = 1.0;
|
||||
};
|
||||
|
||||
double window_s = 0.0; ///< 0 keeps the default window
|
||||
@@ -32,6 +35,15 @@ struct LayoutState {
|
||||
};
|
||||
std::vector<Calibration> calibration;
|
||||
|
||||
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
|
||||
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
|
||||
/// without a session having to name them. Saved by name like every identity here.
|
||||
struct Color {
|
||||
std::string name;
|
||||
std::uint32_t rgb = 0;
|
||||
};
|
||||
std::vector<Color> colors;
|
||||
|
||||
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
|
||||
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
|
||||
/// identity here, because a CONFIG renumbers the channels.
|
||||
@@ -49,8 +61,27 @@ struct LayoutState {
|
||||
/// `math_node_text()` for the field order.
|
||||
std::vector<MathChannel> math;
|
||||
|
||||
/// Whether the measurements pane shows its statistics line. The only piece of the
|
||||
/// statistics that is persisted: the accumulated figures themselves describe a session's
|
||||
/// stream and mean nothing restored against another one.
|
||||
bool measure_stats = false;
|
||||
|
||||
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
||||
std::vector<Measure> measures;
|
||||
|
||||
/// Free text the user attached to this configuration: what it is for, what to look at first.
|
||||
/// A configuration is a *named* session, and the name alone rarely says what makes it different
|
||||
/// from its neighbour.
|
||||
std::string comment;
|
||||
|
||||
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
|
||||
/// why they live here and not only on the command line: two sources on one machine want different
|
||||
/// buffers and different clocks, and retyping the flags to look at the second one is how a
|
||||
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
|
||||
/// the command line's value stands, so an old file cannot silently shrink a buffer.
|
||||
double buffer_mb = 0.0;
|
||||
bool relative_time = false;
|
||||
std::uint32_t capture_packets = 0;
|
||||
};
|
||||
|
||||
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||||
@@ -62,13 +93,23 @@ struct KnownSource {
|
||||
int rows = 0; ///< plots the session had
|
||||
int signals = 0; ///< named slots across those plots
|
||||
std::int64_t mtime = 0; ///< orders the list; never shown
|
||||
/// Which *configuration* of that source this row is. A source may have several, each a named
|
||||
/// session of its own, so the pair (host, port, config) is what identifies a row.
|
||||
std::string config;
|
||||
std::string comment; ///< the configuration's note, shown beside its name
|
||||
/// The startup settings this configuration carries, so the chooser can apply them.
|
||||
double buffer_mb = 0.0;
|
||||
bool relative_time = false;
|
||||
std::uint32_t capture_packets = 0;
|
||||
};
|
||||
|
||||
/// Every session file that names at least one signal, newest first.
|
||||
/// Every configuration that names at least one signal *or* carries a setting, newest first.
|
||||
///
|
||||
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||||
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured — and a
|
||||
/// buffer size, a note or a clock counts as configuring it, because that is how the chooser's form
|
||||
/// sets a host up before it has ever streamed.
|
||||
std::vector<KnownSource> known_sources();
|
||||
|
||||
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||||
@@ -84,11 +125,43 @@ bool math_node_from_text(const std::string& text, MathNode& out);
|
||||
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
|
||||
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
|
||||
std::string config_dir();
|
||||
/// `$XDG_CACHE_HOME/udpscope`, or `~/.cache/udpscope`: where bulky, disposable state
|
||||
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
|
||||
std::string cache_dir();
|
||||
|
||||
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
|
||||
/// Characters unsafe in a filename are replaced in the host part.
|
||||
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
|
||||
/// written under the config directory too.
|
||||
void mkdirs(const std::string& path);
|
||||
|
||||
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
|
||||
/// created. Still the import source for a file written before configurations existed; nothing
|
||||
/// writes here any more.
|
||||
std::string layout_path(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// The per-source directory a source's configurations live in:
|
||||
/// `~/.config/udpscope/<host>_<port>/`. Not created by this call.
|
||||
std::string config_dir_for(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// One configuration's file, `<config_dir_for(host, port)>/<name>.toml`, with the directory
|
||||
/// created. The name is sanitized the way the host is, so a name the user typed with a slash in it
|
||||
/// cannot escape the directory.
|
||||
std::string configuration_path(const std::string& host, std::uint16_t port,
|
||||
const std::string& name);
|
||||
|
||||
/// The source's configurations, newest first. A legacy single-file session appears here as the
|
||||
/// configuration named `default`, and importing it is the first thing this does.
|
||||
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// The configuration the source was last left in: `<dir>/active`, or the newest that exists when
|
||||
/// nothing wrote one. Never empty for a source that has any configuration at all.
|
||||
std::string active_configuration(const std::string& host, std::uint16_t port);
|
||||
|
||||
/// Remembers which configuration this source is on. Called by every save.
|
||||
bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||
|
||||
/// Deletes one configuration's file, and `active` if it named it.
|
||||
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name);
|
||||
|
||||
/// Parses the file's TOML subset. Returns false when the file is missing or
|
||||
/// malformed; `out` is left usable either way (a malformed file loads as much as
|
||||
/// parsed, which beats refusing a hand-edited file over one stray character).
|
||||
|
||||
+46
-6
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "config.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "waveform.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
#include "source_dialog.hpp"
|
||||
@@ -15,6 +17,10 @@ int main(int argc, char** argv) {
|
||||
udpscope::Config cfg;
|
||||
try {
|
||||
cfg = udpscope::parse_args(argc, argv);
|
||||
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
|
||||
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
|
||||
// why this is read here, before the buffers are planned: the mode changes how they are filled.
|
||||
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 2;
|
||||
@@ -22,17 +28,51 @@ int main(int argc, char** argv) {
|
||||
|
||||
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||||
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||||
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
|
||||
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
|
||||
// fills `open_waveform` in instead of the connection fields.
|
||||
if (!cfg.server_specified && cfg.open_waveform.empty() && !udpscope::choose_source(cfg)) return 0;
|
||||
// A source named on the command line skips the chooser, but its *stored* configuration still
|
||||
// applies: the buffer size, the clock and the capture ring are decided here, before the model --
|
||||
// which is the only moment they can be -- and a saved host whose buffer was ignored because the
|
||||
// address came from the command line is an edit that looks like it never took effect.
|
||||
if (cfg.server_specified) udpscope::apply_stored_settings(cfg);
|
||||
|
||||
udpscope::ScopeModel model(cfg);
|
||||
udpscope::RowSet rows;
|
||||
rows.set_row_count(cfg.plots);
|
||||
udpscope::Acquisition acquisition(model, cfg);
|
||||
try {
|
||||
acquisition.start();
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 1;
|
||||
if (cfg.open_waveform.empty()) {
|
||||
try {
|
||||
acquisition.start();
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// No socket and no thread: the file is the source, and there is nothing to arrive afterwards.
|
||||
// The acquisition object is passed to the UI all the same -- stopped, which is what every
|
||||
// "paused"/"capturing" question answers as false.
|
||||
udpscope::WaveformLoadReport report;
|
||||
std::string error;
|
||||
if (!udpscope::load_waveform_into_model(model, cfg.open_waveform, &report, &error)) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
// One row per column, so every trace opens where a user can see and rearrange it -- and sized
|
||||
// *here*, before the UI exists, exactly as `--plots` does: resizing the rows from inside the
|
||||
// activation leaves the panels built for the old count.
|
||||
rows.set_row_count(std::min<int>(std::max<int>(cfg.plots, static_cast<int>(report.signals)),
|
||||
udpscope::RowSet::kMaxRows));
|
||||
udpscope::remember_waveform(cfg.open_waveform);
|
||||
// The whole file framed from the start: the window is the recording's own span, and nothing
|
||||
// afterwards restates it -- a zoom is the user's.
|
||||
if (report.span_s > 0.0) rows.set_time_window(report.span_s);
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] waveform loaded: %s signals=%zu samples=%zu span=%.6fs rate=%.6g "
|
||||
"time=[%.6f..%.6f]\n",
|
||||
cfg.open_waveform.c_str(), report.signals, report.samples, report.span_s,
|
||||
report.rate_hz, report.first_time, report.last_time);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows);
|
||||
|
||||
+23
-4
@@ -61,6 +61,13 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
|
||||
/// that actually need carrying between samples.
|
||||
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
|
||||
double& z2) {
|
||||
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
|
||||
// permanent: every later sample would come out NaN, so a filtered trace died at the first
|
||||
// gap and never came back -- which is what a dropped packet did to every low pass in
|
||||
// relative mode, where a counter gap reserves NaN slots by construction. A gap in the input
|
||||
// is a gap in the output (the hole stays visible), and the filter's history is the last
|
||||
// *real* samples it saw, so the state is left alone and the trace resumes after the hole.
|
||||
if (!std::isfinite(x)) return kNaN;
|
||||
const double y = b0 * x + z1;
|
||||
z1 = b1 * x - a1 * y + z2;
|
||||
z2 = b2 * x - a2 * y;
|
||||
@@ -106,6 +113,13 @@ MathOp math_op_at(int index) {
|
||||
}
|
||||
}
|
||||
|
||||
int math_op_index(MathOp op) {
|
||||
for (int i = 0; i < math_op_count(); ++i) {
|
||||
if (math_op_at(i) == op) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int math_op_arity(MathOp op) {
|
||||
switch (op) {
|
||||
case MathOp::Source: return 0;
|
||||
@@ -215,15 +229,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
return rts[static_cast<std::size_t>(out)].value;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
const std::size_t n = channel.nodes.size();
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
|
||||
const std::size_t n = nodes.size();
|
||||
const auto fail = [&](const std::string& text) {
|
||||
if (why != nullptr) *why = text;
|
||||
return false;
|
||||
};
|
||||
if (n == 0) return fail("empty graph");
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const MathNode& node = channel.nodes[i];
|
||||
const MathNode& node = nodes[i];
|
||||
if (node.op == MathOp::Source && node.source.empty()) {
|
||||
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
||||
}
|
||||
@@ -238,10 +252,15 @@ bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
|
||||
if (output < 0 || static_cast<std::size_t>(output) >= n) {
|
||||
return fail("no output chosen");
|
||||
}
|
||||
if (why != nullptr) why->clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
return math_channel_valid_nodes(channel.nodes, channel.output, why);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -30,6 +30,10 @@ enum class MathOp {
|
||||
const char* math_op_name(MathOp op);
|
||||
int math_op_count();
|
||||
MathOp math_op_at(int index);
|
||||
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
|
||||
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
|
||||
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
|
||||
int math_op_index(MathOp op);
|
||||
/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic.
|
||||
int math_op_arity(MathOp op);
|
||||
/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows --
|
||||
@@ -54,6 +58,17 @@ struct MathNode {
|
||||
double hi = 1.0;
|
||||
double x = 0.0; ///< the editor's box position, logical pixels.
|
||||
double y = 0.0;
|
||||
|
||||
/// Whether two nodes are the same node for the *engine's* purposes.
|
||||
///
|
||||
/// `x`/`y` are the canvas's own and no op reads them, so a graph that only moved is not a change:
|
||||
/// rebuilding for it replaces the buffer and drops its history -- and in a file view that means
|
||||
/// re-reading the file -- to store two numbers nothing but the editor draws with.
|
||||
bool operator==(const MathNode& other) const {
|
||||
return op == other.op && source == other.source && in_a == other.in_a && in_b == other.in_b &&
|
||||
gain == other.gain && offset == other.offset && fc == other.fc && q == other.q &&
|
||||
lo == other.lo && hi == other.hi;
|
||||
}
|
||||
};
|
||||
|
||||
/// A derived channel: a graph of nodes and which node is the answer.
|
||||
@@ -63,6 +78,11 @@ struct MathChannel {
|
||||
bool enabled = false;
|
||||
std::vector<MathNode> nodes;
|
||||
int output = -1; ///< the node whose value is the channel's.
|
||||
|
||||
bool operator==(const MathChannel& other) const {
|
||||
return name == other.name && unit == other.unit && enabled == other.enabled &&
|
||||
output == other.output && nodes == other.nodes;
|
||||
}
|
||||
};
|
||||
|
||||
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
|
||||
@@ -117,4 +137,9 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
/// input it needs connected to a node that exists (with `Source` nodes naming something).
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
||||
|
||||
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
|
||||
/// a `MathChannel` -- the editor, and the headless probe that drives it.
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
|
||||
std::string* why = nullptr);
|
||||
|
||||
} // namespace udpscope
|
||||
+63
-15
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
||||
acc.open[e] = true;
|
||||
}
|
||||
|
||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
||||
/// on anything less square than a sine.
|
||||
constexpr double kHysteresisFrac = 0.10;
|
||||
|
||||
} // namespace
|
||||
|
||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||
const double span = s.hi - s.lo;
|
||||
acc.level = s.lo + pct * span;
|
||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
|
||||
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
|
||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
||||
}
|
||||
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = static_cast<double>(chunk.value[i]);
|
||||
if (!std::isfinite(v)) continue;
|
||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||
// below the threshold, and a band here would move a documented exact reading.
|
||||
const int s = v > acc.level ? 1 : 0;
|
||||
acc.sum[static_cast<std::size_t>(s)] += v;
|
||||
++acc.count[static_cast<std::size_t>(s)];
|
||||
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
|
||||
// the side it last decided. That is what stops a signal which merely touches the level from
|
||||
// counting every sample as an edge.
|
||||
if (v == acc.level) continue;
|
||||
if (acc.have_side && s != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
|
||||
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
|
||||
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
|
||||
// signal that merely wobbles about the level from counting every wobble as an edge. With
|
||||
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
|
||||
int d;
|
||||
if (v > acc.level + acc.hysteresis) {
|
||||
d = 1;
|
||||
} else if (v < acc.level - acc.hysteresis) {
|
||||
d = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
acc.side = s != 0;
|
||||
if (acc.have_side && d != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
|
||||
}
|
||||
acc.side = d != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
}
|
||||
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
||||
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
||||
if (k.count != k.span) return false;
|
||||
// Strictly on one side, so every sample decides the same side and none is *at* the level --
|
||||
// which is what makes this exact rather than an approximation of the walk.
|
||||
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
||||
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
||||
const bool above = k.lo > acc.level;
|
||||
const bool below = k.hi < acc.level;
|
||||
if (!above && !below) return false;
|
||||
const std::size_t e = above ? 1u : 0u;
|
||||
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
||||
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
||||
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
||||
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
||||
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
||||
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
||||
// whose position is inside the bucket is refused.
|
||||
const double top = acc.level + acc.hysteresis;
|
||||
const double bot = acc.level - acc.hysteresis;
|
||||
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
||||
// raw sample inside it decides none, so the walk state is left alone.
|
||||
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
||||
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
||||
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
||||
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
||||
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
||||
if (change && !fully_outside) return false;
|
||||
acc.sum[e] += k.sum;
|
||||
acc.count[e] += k.count;
|
||||
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
|
||||
// The edge is the bucket's *first* sample: everything before it was on the other side and
|
||||
// everything in the bucket is on this one, so that is where the raw walk would have found it.
|
||||
enter_side(acc, e, time_of(k.offset));
|
||||
if (change) enter_side(acc, e, time_of(k.offset));
|
||||
if (reaches) {
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+73
-4
@@ -2,7 +2,9 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -152,6 +154,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
||||
/// must adding a second measurement of the same type at the same level.
|
||||
struct MeasureLevel {
|
||||
double level = 0.0;
|
||||
/// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal
|
||||
/// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample
|
||||
/// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count
|
||||
/// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed
|
||||
/// several times per true crossing by any interferer faster than the fundamental, because near
|
||||
/// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a
|
||||
/// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading
|
||||
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
||||
///
|
||||
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
||||
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
|
||||
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
|
||||
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
|
||||
double hysteresis = 0.0;
|
||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
||||
/// `bottom` has always reported), index 1 the side above it.
|
||||
double sum[2] = {0.0, 0.0};
|
||||
@@ -170,6 +186,14 @@ struct MeasureLevel {
|
||||
bool open[2] = {false, false};
|
||||
};
|
||||
|
||||
/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share
|
||||
/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule:
|
||||
/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the
|
||||
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
||||
|
||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||
/// measurements on one signal costs two walks, not two per measurement.
|
||||
@@ -182,10 +206,14 @@ void measure_accumulate(const MeasureChunk& chunk,
|
||||
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
|
||||
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
|
||||
///
|
||||
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
|
||||
/// the edge's position a guess -- and the caller must feed its raw samples to
|
||||
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
|
||||
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
|
||||
/// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
|
||||
/// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
|
||||
/// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
|
||||
/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would
|
||||
/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the
|
||||
/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the
|
||||
/// first test -- see the comment in the implementation for why refusing every bucket inside the band
|
||||
/// costs a share of the window rather than a bucket.
|
||||
bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
||||
const std::function<double(std::size_t)>& time_of,
|
||||
MeasureLevel& level);
|
||||
@@ -218,4 +246,45 @@ bool measure_uses_side(MeasureType type);
|
||||
/// Whether a type draws its result on the plot when asked.
|
||||
bool measure_can_show_on_plot(MeasureType type);
|
||||
|
||||
/// Running statistics over a measurement's successive results.
|
||||
///
|
||||
/// One observation per *recompute* of the measurements pane, never one per sample: a
|
||||
/// measurement is a figure derived from a whole window, and counting its samples would
|
||||
/// describe the window rather than the measurement's stability. `n` is therefore a count of
|
||||
/// passes, which is what makes the readout comparable with a bench scope's.
|
||||
///
|
||||
/// The mean and spread are Welford's recurrence rather than running sums of `v` and `v*v`:
|
||||
/// the results are absolute signal values, so a channel sitting at a large offset with a
|
||||
/// small ripple -- the case the readout is *for* -- cancels to nothing in a naive
|
||||
/// sum-of-squares long before the ripple is visible.
|
||||
struct MeasureStatsAccum {
|
||||
std::uint64_t n = 0;
|
||||
double mean = 0.0; ///< Welford's running mean.
|
||||
double m2 = 0.0; ///< Welford's sum of squared deviations from the running mean.
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
|
||||
void add(double v) noexcept {
|
||||
if (n == 0) {
|
||||
lo = v;
|
||||
hi = v;
|
||||
} else {
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
++n;
|
||||
const double delta = v - mean;
|
||||
mean += delta / static_cast<double>(n);
|
||||
m2 += delta * (v - mean);
|
||||
}
|
||||
|
||||
void reset() noexcept { *this = MeasureStatsAccum{}; }
|
||||
|
||||
/// The sample standard deviation (n-1), and 0 rather than NaN for fewer than two
|
||||
/// observations: the readout is a figure in a label, and a single pass has no spread.
|
||||
double sd() const noexcept {
|
||||
return n < 2 ? 0.0 : std::sqrt(m2 / static_cast<double>(n - 1));
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
+284
-24
@@ -43,7 +43,7 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
||||
} // namespace
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s) {
|
||||
double target_span_s, bool with_abs_time) {
|
||||
Partition p;
|
||||
const std::size_t n = channel_dt.size();
|
||||
p.stride.assign(n, 1);
|
||||
@@ -69,12 +69,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
// affects every channel equally and goes through the ingest filter.
|
||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||
|
||||
p.abs_time = with_abs_time;
|
||||
|
||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
||||
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
|
||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||
|
||||
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||
@@ -99,7 +101,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||
};
|
||||
const auto bytes_for = [&](u64 ticks) {
|
||||
u64 total = ticks * sizeof(f64);
|
||||
u64 total = ticks * sizeof(f64) * (with_abs_time ? 2u : 1u);
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||
return total;
|
||||
};
|
||||
@@ -139,6 +141,11 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
: part_(part),
|
||||
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||
(void)span_s;
|
||||
// Same capacity as the clock it parallels: the two are written on the same slots, so a
|
||||
// smaller ring would make an export's stamps run out before its values do.
|
||||
if (part.abs_time) {
|
||||
abs_time_ = std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity);
|
||||
}
|
||||
const std::size_t n = part.value_capacity.size();
|
||||
values_.reserve(n);
|
||||
channels_.reserve(n);
|
||||
@@ -163,6 +170,35 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
|
||||
trigger_.holdoff_s = cfg.trigger_holdoff_s;
|
||||
}
|
||||
|
||||
namespace {
|
||||
/// True when the incoming CONFIG names exactly the stream the model already has: same
|
||||
/// signals, same shapes, rates, ranges and time modes. A reconnect re-sends the CONFIG,
|
||||
/// and EMI makes that happen at the worst possible moment; the buffer, its history, the
|
||||
/// holes, the trigger's held event and every measurement are all still valid and must
|
||||
/// survive the reconnect untouched. Only a *different* stream is worth a re-plan, and a
|
||||
/// re-plan is what drops history (see `rebuild_locked`).
|
||||
bool same_stream_signals(const std::vector<SignalInfo>& incoming,
|
||||
const std::vector<SignalInfo>& current) {
|
||||
// `current` is the stream's signals with the math channels' synthetic entries appended,
|
||||
// so compare the incoming list against current's prefix, skipping nothing on the left.
|
||||
std::size_t n_stream = 0;
|
||||
while (n_stream < current.size() && !current[n_stream].math) ++n_stream;
|
||||
if (incoming.size() != n_stream) return false;
|
||||
for (std::size_t i = 0; i < incoming.size(); ++i) {
|
||||
const SignalInfo& a = incoming[i];
|
||||
const SignalInfo& b = current[i];
|
||||
if (a.name != b.name || a.unit != b.unit || a.type_code != b.type_code ||
|
||||
a.quant_type != b.quant_type || a.rows != b.rows || a.cols != b.cols ||
|
||||
a.range_min != b.range_min || a.range_max != b.range_max ||
|
||||
a.time_mode != b.time_mode || a.sampling_rate != b.sampling_rate ||
|
||||
a.time_signal_idx != b.time_signal_idx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// Keep the enable state across CONFIG updates for signals that kept their name. The math
|
||||
@@ -177,6 +213,14 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
|
||||
signals.erase(std::remove_if(signals.begin(), signals.end(),
|
||||
[](const SignalInfo& s) { return s.math; }),
|
||||
signals.end());
|
||||
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
|
||||
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- 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.
|
||||
std::fprintf(stderr, "[udpscope] CONFIG unchanged: keeping the buffer and its history\n");
|
||||
std::fflush(stderr);
|
||||
return;
|
||||
}
|
||||
signals_ = std::move(signals);
|
||||
enabled_.assign(signals_.size(), false);
|
||||
for (std::size_t i = 0; i < signals_.size(); ++i) {
|
||||
@@ -213,6 +257,65 @@ void ScopeModel::append_math_signals_locked() {
|
||||
|
||||
void ScopeModel::set_math_channels(std::vector<MathChannel> channels) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// An identical list is not a change. The rebuild below replaces the buffer and drops its history --
|
||||
// which a file view then has to re-read from disk -- and `apply_math_channels()` runs this on every
|
||||
// session start, where a session with no graphs hands over an empty list against a model that has
|
||||
// none either. Comparing the graphs is what makes that call free.
|
||||
if (channels == math_) return;
|
||||
// An edit of an existing graph -- the same channels, names and enables -- does not move the *plan*, which
|
||||
// is a partition over the channels. Nothing has to be rebuilt, and every other channel keeps its history:
|
||||
// only the edited graphs, and the channels that read them, are recomputed.
|
||||
bool same_set = channels.size() == math_.size();
|
||||
for (std::size_t i = 0; same_set && i < channels.size(); ++i) {
|
||||
same_set = channels[i].name == math_[i].name && channels[i].enabled == math_[i].enabled;
|
||||
}
|
||||
if (same_set) {
|
||||
const auto math_index_of = [this](const std::string& name) -> int {
|
||||
for (std::size_t k = 0; k < math_.size(); ++k) {
|
||||
if (math_[k].name == name) return static_cast<int>(k);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
std::vector<bool> affected(math_.size(), false);
|
||||
std::vector<bool> edited(math_.size(), false);
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (!(channels[i] == math_[i])) {
|
||||
affected[i] = true;
|
||||
edited[i] = true;
|
||||
}
|
||||
}
|
||||
// A channel's output is another's *input*, so whatever names a changed channel is recomputed too -- and
|
||||
// nothing else is touched at all.
|
||||
for (bool moved = true; moved;) {
|
||||
moved = false;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (!affected[i]) continue;
|
||||
for (const MathNode& node : channels[i].nodes) {
|
||||
if (node.op != MathOp::Source) continue;
|
||||
const int k = math_index_of(node.source);
|
||||
if (k >= 0 && !affected[static_cast<std::size_t>(k)]) {
|
||||
affected[static_cast<std::size_t>(k)] = true;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
math_ = std::move(channels);
|
||||
const auto index_of = [this](const std::string& name) -> int {
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (channels_[c].name == name) return static_cast<int>(c);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
// Every affected channel is prepared again: a runtime carried over from the old graph is history for a
|
||||
// filter nobody asked for, and one whose *inputs* changed is history for inputs it no longer has.
|
||||
for (std::size_t i = 0; i < math_.size(); ++i) {
|
||||
if (affected[i]) math_prepare(math_[i], math_rt_[i], part_.slot_dt(), index_of);
|
||||
}
|
||||
recompute_math_locked(affected);
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
math_ = std::move(channels);
|
||||
// Lift the previous synthetic entries out of the signal list; `append_math_signals_locked()`
|
||||
// puts the current ones back, so an edit is the same kind of structural rebuild as a CONFIG.
|
||||
@@ -250,7 +353,38 @@ void ScopeModel::prepare_math_locked() {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!store_) return;
|
||||
BufferStore& store = *store_;
|
||||
const std::size_t channels = channels_.size();
|
||||
std::vector<f32> slot(channels, 0.0f);
|
||||
for (std::size_t i = 0; i < channels; ++i) {
|
||||
// A derived channel has no column of its own: `push_math_sample()` below fills it, and pushing the
|
||||
// file's value here as well would put *two* samples in its ring per slot -- a trace at double rate.
|
||||
// The same rule the packet path states in `ChannelInfo::math`: the ingest must skip it.
|
||||
if (channels_[i].math) continue;
|
||||
const f32 v = i < values.size() ? values[i] : 0.0f;
|
||||
store.values(i).push(v);
|
||||
slot[i] = v;
|
||||
}
|
||||
store.time().push(t);
|
||||
if (auto* abs = store.abs_time()) abs->push(abs_t);
|
||||
push_math_sample(slot);
|
||||
}
|
||||
|
||||
std::uint64_t ScopeModel::stored_slots() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return store_ ? store_->time().size() : 0;
|
||||
}
|
||||
|
||||
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
// The producer's entry: every derived channel, and timed (see the cost sampling below).
|
||||
math_apply_locked(sample, nullptr, true);
|
||||
}
|
||||
|
||||
void ScopeModel::math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask,
|
||||
bool timed) {
|
||||
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
|
||||
// early-out is what keeps a stream with no math from paying for the feature.
|
||||
if (math_.empty()) return;
|
||||
@@ -259,9 +393,14 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
|
||||
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
|
||||
// overall, and the figure converges over the millions this counter sees.
|
||||
const std::uint64_t slot = math_slot_count_++;
|
||||
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
|
||||
const bool timed = slot % kMathCostSample == 0;
|
||||
// The sampling counts *producer* slots: a recompute must not inflate `math_slots_`, which the dump
|
||||
// reports as the run's own cadence.
|
||||
std::uint64_t slot = 0;
|
||||
if (timed) {
|
||||
slot = math_slot_count_++;
|
||||
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
|
||||
timed = slot % kMathCostSample == 0;
|
||||
}
|
||||
if (timed) math_cost_t0_ = now_ns();
|
||||
// Values for this slot, extended as the derived ones are computed: a graph may name another math
|
||||
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
|
||||
@@ -274,7 +413,11 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
if (channel < 0 || m >= math_rt_.size()) continue;
|
||||
const double out =
|
||||
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
|
||||
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
|
||||
// A recompute computes every channel -- the graphs are a list walked in order, so a channel's inputs
|
||||
// have to be there -- but only the affected ones take the sample.
|
||||
if (write_mask == nullptr || (*write_mask)[m]) {
|
||||
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
|
||||
}
|
||||
if (static_cast<std::size_t>(channel) < math_values_.size()) {
|
||||
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
|
||||
}
|
||||
@@ -287,6 +430,48 @@ void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
|
||||
if (!store_ || math_.empty()) return;
|
||||
BufferStore& store = *store_;
|
||||
const std::size_t slots = static_cast<std::size_t>(store.time().size());
|
||||
if (slots == 0) return;
|
||||
|
||||
std::vector<bool> derived(channels_.size(), false);
|
||||
for (std::size_t m = 0; m < math_.size(); ++m) {
|
||||
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
|
||||
if (channel < 0) continue;
|
||||
derived[static_cast<std::size_t>(channel)] = true;
|
||||
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
|
||||
// are untouched, so the state they hold is the answer to the inputs they have always had.
|
||||
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
|
||||
}
|
||||
|
||||
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
|
||||
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
|
||||
// ring here would read out of range.
|
||||
constexpr std::size_t kChunk = 1u << 14;
|
||||
const std::size_t base = static_cast<std::size_t>(store.time().base());
|
||||
std::vector<std::vector<f32>> inputs(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (!derived[c]) inputs[c].resize(kChunk);
|
||||
}
|
||||
std::vector<f32> slot(channels_.size(), 0.0f);
|
||||
for (std::size_t start = 0; start < slots; start += kChunk) {
|
||||
const std::size_t n = std::min(kChunk, slots - start);
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
if (derived[c]) continue;
|
||||
store.values(c).copy_from(base + start, n, inputs[c].data());
|
||||
}
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
slot[c] = derived[c] ? 0.0f : inputs[c][i];
|
||||
}
|
||||
math_apply_locked(slot, &affected, /*timed=*/false);
|
||||
}
|
||||
}
|
||||
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_channels() {
|
||||
channels_.clear();
|
||||
// Which channel each math channel ended up with, by name: a disabled one has none, and the
|
||||
@@ -329,6 +514,15 @@ void ScopeModel::rebuild_channels() {
|
||||
}
|
||||
|
||||
void ScopeModel::rebuild_locked() {
|
||||
// The store is replaced below, so every hole's `slot_begin`/`tick_begin`/`x_begin` describes a
|
||||
// buffer that no longer exists: the new store's base is 0, so `forget_holes_before()` cannot
|
||||
// retire them and they would shade a band over the wrong x until the table pushed them out.
|
||||
// Dropping them is the honest answer -- the samples they reported absent are gone with the
|
||||
// history. `++plan_generation_` is what tells the producer the same thing about `tick_`, whose
|
||||
// relation to `produced()` is what addresses a backfill (see `Acquisition::on_data`).
|
||||
holes_.clear();
|
||||
++plan_generation_;
|
||||
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
@@ -340,7 +534,7 @@ void ScopeModel::rebuild_locked() {
|
||||
}
|
||||
|
||||
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
|
||||
target_span_.load(std::memory_order_relaxed));
|
||||
target_span_.load(std::memory_order_relaxed), cfg_.relative_time);
|
||||
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
|
||||
|
||||
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
|
||||
@@ -358,7 +552,7 @@ bool ScopeModel::set_target_span(double seconds) {
|
||||
std::vector<double> dts;
|
||||
dts.reserve(channels_.size());
|
||||
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds);
|
||||
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
|
||||
if (want.time_stride == part_.time_stride) return false;
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
|
||||
@@ -425,6 +619,13 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
|
||||
generation_.fetch_add(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
std::pair<double, double> ScopeModel::calibration(std::size_t signal) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (signal >= signals_.size()) return {1.0, 0.0};
|
||||
const auto it = calibration_.find(signals_[signal].name);
|
||||
return it != calibration_.end() ? it->second : std::pair<double, double>{1.0, 0.0};
|
||||
}
|
||||
|
||||
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
|
||||
@@ -450,6 +651,13 @@ double ScopeModel::channel_dt(std::size_t channel) const {
|
||||
BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
// There may be **no store at all**: `rebuild_locked()` resets it for a stream with no channels, which
|
||||
// is what the model is in until a CONFIG arrives. `live()` dereferences the pointer, so every path
|
||||
// that can be reached without a stream has to ask first -- pausing a scope that is not connected did
|
||||
// not, and walked into this function through `publish_pause_view()`, a null dereference.
|
||||
first = 0;
|
||||
last = 0;
|
||||
if (store_ == nullptr) return;
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
@@ -501,21 +709,14 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
if (last < first) last = first;
|
||||
}
|
||||
|
||||
void ScopeModel::reset_live_history() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
reset_live_history_locked();
|
||||
}
|
||||
|
||||
void ScopeModel::reset_live_history_locked() {
|
||||
BufferStore& src = live();
|
||||
src.time().clear();
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
src.values(c).clear();
|
||||
src.channel(c).invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
// Nothing has arrived yet, so there is nothing to freeze: pausing a scope that is not connected is a
|
||||
// legitimate gesture (the button works), and it must not take a window from a store that does not
|
||||
// exist.
|
||||
if (store_ == nullptr) return;
|
||||
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||
// doing it, and this one is the UI's.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = x_offset;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -535,8 +736,15 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
src.time().copy_from(first, n, snap->stamps.data());
|
||||
for (f64& t : snap->stamps) t -= x_offset;
|
||||
}
|
||||
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||
}
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
snap->pyramids.resize(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
const u64 stride = std::max<u64>(1, part_.stride[c]);
|
||||
auto& vals = snap->values[c];
|
||||
@@ -545,16 +753,42 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
|
||||
f32 lo = vals.empty() ? 0.0f : vals.front();
|
||||
f32 hi = lo;
|
||||
// The LOD index, folded into the bounds pass exactly as `publish_event()` folds it, and for
|
||||
// the same reason: a snapshot that answers `pyramid()` with an empty one has `level_for()`
|
||||
// descend to level 0, so the frozen view is re-gathered from every raw sample it holds on
|
||||
// every frame it is on screen. A pause is not a cheaper case than a capture -- it freezes the
|
||||
// *same* rings -- and leaving it out was visible twice over. The cost: `frame path:` read
|
||||
// `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
|
||||
// draw mean 12.34 -> 18.18 ms. And the *look* changed, which is the half a cost figure does
|
||||
// not show: `stage_trace_density()` grades a column against its own peak from a `1/height`
|
||||
// deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far
|
||||
// spikier distribution -- and a far larger peak to normalise against -- than one fed the one
|
||||
// or two bucket envelopes the pyramid gives. The paused trace read as a hard bright core with
|
||||
// its dwell tail dimmed out, i.e. the trace changed appearance on pause.
|
||||
//
|
||||
// It is not free, and the trade is one-off against per-frame: building it took the pause
|
||||
// publish from 6.15 ms to 22.31 ms (`publish cost:`), which blocks the *producer* -- against
|
||||
// ~5 ms a frame of draw for as long as the pause lasts, which at ~60 fps a 1.6 s pause repays
|
||||
// thirty times over. The producer absorbs the stall: `gaps=0` with and without a pause, the
|
||||
// paused run having published in 86 ms.
|
||||
auto& pyr = snap->pyramids[c];
|
||||
pyr.reserve(take);
|
||||
for (f32 v : vals) {
|
||||
lo = std::min(lo, v);
|
||||
hi = std::max(hi, v);
|
||||
pyr.append(v);
|
||||
}
|
||||
snap->y[c] = plot::Range{lo, hi};
|
||||
}
|
||||
pause_view_.store(std::move(snap), std::memory_order_release);
|
||||
note_publish(publish_pause_count_, publish_pause_us_total_, publish_pause_us_worst_,
|
||||
(now_ns() - t0) / 1000u);
|
||||
}
|
||||
|
||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// The caller is the producer and already holds `mutex()`, so every microsecond counted here is a
|
||||
// microsecond the UI tick is blocked for -- which is what the documented `tick` max is made of.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
|
||||
BufferStore& src = live();
|
||||
@@ -572,6 +806,12 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// disagree with.
|
||||
for (auto& t : snap->stamps) t -= edge;
|
||||
}
|
||||
if (const plot::data::RingBuffer<f64>* abs = src.abs_time(); abs != nullptr) {
|
||||
snap->abs_stamps.resize(static_cast<std::size_t>(n));
|
||||
abs->copy_from(first, n, snap->abs_stamps.data());
|
||||
// Absolute seconds outright -- deliberately not rebased like `stamps`.
|
||||
for (f64& t : snap->abs_stamps) t += epoch_;
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
@@ -605,6 +845,8 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
||||
// number sees the snapshot it counts.
|
||||
event_seq_.fetch_add(1, std::memory_order_release);
|
||||
note_publish(publish_event_count_, publish_event_us_total_, publish_event_us_worst_,
|
||||
(now_ns() - t0) / 1000u);
|
||||
}
|
||||
|
||||
void BufferStore::set_epoch(f64 epoch) noexcept {
|
||||
@@ -617,7 +859,25 @@ void ScopeModel::set_display_epoch(f64 e) noexcept {
|
||||
}
|
||||
|
||||
void ScopeModel::acquire_all() {
|
||||
if (store_) store_->acquire_all();
|
||||
if (store_) {
|
||||
store_->acquire_all();
|
||||
// Drop holes whose slots the time ring has already overwritten: their slot addresses
|
||||
// would alias live slots once the ring wraps past them, and they can no longer be
|
||||
// backfilled or drawn. `base()` is the absolute index of the oldest slot the ring
|
||||
// still retains -- anything earlier has already been discarded.
|
||||
forget_holes_before(store_->time().base());
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::note_hole(const Hole& h) {
|
||||
if (holes_.size() >= kMaxHoles) holes_.erase(holes_.begin());
|
||||
holes_.push_back(h);
|
||||
}
|
||||
|
||||
void ScopeModel::forget_holes_before(u64 slot) {
|
||||
// A hole whose last slot the ring has dropped can no longer be backfilled or drawn, and
|
||||
// its address would alias a live slot once the ring wraps past it.
|
||||
std::erase_if(holes_, [slot](const Hole& h) { return h.slot_begin + h.slot_count <= slot; });
|
||||
}
|
||||
|
||||
void ScopeModel::set_trigger_enabled(bool on) {
|
||||
|
||||
+169
-15
@@ -93,6 +93,11 @@ struct Partition {
|
||||
double base_dt = 1.0; ///< seconds per base tick
|
||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||
|
||||
/// True when a second f64 ring parallel to the clock was planned and paid for in the
|
||||
/// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute
|
||||
/// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing.
|
||||
bool abs_time = false;
|
||||
|
||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||
/// The rate samples are actually stored at, in samples per second.
|
||||
@@ -109,9 +114,10 @@ struct Partition {
|
||||
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
|
||||
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
|
||||
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
|
||||
/// one sample per channel.
|
||||
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
|
||||
/// and sets `Partition::abs_time`.
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s);
|
||||
double target_span_s, bool with_abs_time = false);
|
||||
|
||||
/// One of the two identical sample buffers: a shared time-base ring plus one
|
||||
/// value ring and one `ChannelBuffer` per channel.
|
||||
@@ -132,6 +138,15 @@ class BufferStore {
|
||||
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||
/// The stream's **absolute** clock, parallel to `time()` and on the same slots, or
|
||||
/// `nullptr` when the store was planned without it.
|
||||
///
|
||||
/// There is exactly one time ring per store, shared by every channel, and relative mode
|
||||
/// fills it with counter-derived stamps -- so without this ring, selecting relative mode
|
||||
/// would silently drop absolute time from an export, the one output `todo.md` item 2 says
|
||||
/// must keep it. Allocated in relative mode only; read only by the export path.
|
||||
plot::data::RingBuffer<f64>* abs_time() noexcept { return abs_time_.get(); }
|
||||
const plot::data::RingBuffer<f64>* abs_time() const noexcept { return abs_time_.get(); }
|
||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||
@@ -147,10 +162,45 @@ class BufferStore {
|
||||
private:
|
||||
Partition part_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f64>> abs_time_;
|
||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||
};
|
||||
|
||||
/// One reserved, invalidated span: samples the counter says exist and the stream has not
|
||||
/// delivered. Its stored slots hold NaN, so a reader needs no special case -- the table is
|
||||
/// what lets a *late* packet find the slots it belongs to, and what the band draws.
|
||||
struct Hole {
|
||||
f64 x_begin = 0.0; ///< stream seconds of the hole's first slot
|
||||
f64 x_end = 0.0; ///< stream seconds of the hole's last slot
|
||||
/// Absolute **time-ring** slot of the first reserved slot, and the count of them. The
|
||||
/// time ring is the one clock every channel shares, so this is the only slot address
|
||||
/// that is unambiguous: per-channel value rings run at `time_stride * stride[i]`, which
|
||||
/// differs between channels. A consumer that needs a *channel's* slots derives them from
|
||||
/// `tick_begin` and that channel's stride rather than reusing these.
|
||||
u64 slot_begin = 0;
|
||||
u64 slot_count = 0;
|
||||
u64 tick_begin = 0; ///< absolute base tick of the hole's first raw sample
|
||||
std::uint32_t counter_first = 0; ///< counter of the first missing packet
|
||||
std::uint32_t packets = 0; ///< packets the hole is worth
|
||||
/// How many of those packets have since arrived late and been written in. The band's two tints
|
||||
/// are this field against `packets`: a hole still missing data is shaded solidly and a fully
|
||||
/// repaired one faintly, so a reader can tell "there is no data here" from "there was none when
|
||||
/// this window was first drawn".
|
||||
///
|
||||
/// Counted in *packets* and not in slots, because a packet is the unit the reservation was made
|
||||
/// in: the hole is worth exactly `packets` of them, each late arrival delivers exactly one, and
|
||||
/// the client refuses a duplicate -- so this count is exact. A slot count would not be: the
|
||||
/// channels have different strides, so "how many slots did this hole want" has one answer per
|
||||
/// channel and the repaired test would have to pick one.
|
||||
std::uint32_t filled_packets = 0;
|
||||
|
||||
/// Every packet this hole was reserved for has arrived. A hole worth no packets is never
|
||||
/// repaired -- there is nothing for a late packet to deliver -- which keeps a degenerate
|
||||
/// reservation shaded rather than silently downgraded.
|
||||
bool repaired() const noexcept { return packets > 0 && filled_packets >= packets; }
|
||||
};
|
||||
|
||||
/// Live connection and counter state, readable from the UI thread.
|
||||
struct StreamStats {
|
||||
std::atomic<std::uint64_t> packets{0};
|
||||
@@ -159,6 +209,27 @@ struct StreamStats {
|
||||
std::atomic<std::uint64_t> reconnects{0};
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<bool> have_config{false};
|
||||
std::atomic<std::uint64_t> holes{0}; ///< reserved invalidated spans
|
||||
std::atomic<std::uint64_t> hole_slots{0}; ///< stored slots those spans cover
|
||||
/// Out-of-order packets put back in order inside the reorder window: no hole was ever
|
||||
/// seen for these.
|
||||
std::atomic<std::uint64_t> resequenced{0};
|
||||
/// Out-of-order packets delivered behind the sequence, past the window.
|
||||
std::atomic<std::uint64_t> late{0};
|
||||
/// Times the client's reorder ring was full when a packet needed a slot, so the gap was
|
||||
/// committed early. Nothing is dropped; the window was just shorter than asked for, and a
|
||||
/// non-zero reading is what says the ring is undersized for the stream.
|
||||
std::atomic<std::uint64_t> overflows{0};
|
||||
/// Packets the client still refused outright (a duplicate, or any late packet while
|
||||
/// resequencing is disabled). Counted since the beginning and displayed nowhere until
|
||||
/// now, which is why an inversion had to be inferred from `packets` and `frames`
|
||||
/// diverging.
|
||||
std::atomic<std::uint64_t> stale{0};
|
||||
/// Stored slots a late packet repaired. Declared here rather than with the backfill that
|
||||
/// writes it, because the display segment below is built once and reads it from the start:
|
||||
/// a reading of 0 beside a non-zero `holes` is exactly the honest answer until the
|
||||
/// backfill lands.
|
||||
std::atomic<std::uint64_t> backfilled{0};
|
||||
};
|
||||
|
||||
/// What the plot is showing, which is one decision made once per frame: the bind, the
|
||||
@@ -209,6 +280,26 @@ class ScopeModel {
|
||||
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
|
||||
/// evaluated from those, in order, so a math channel may use another one.
|
||||
void push_math_sample(const std::vector<f32>& sample);
|
||||
|
||||
/// Applies the graphs to one stored slot. `write_mask`, when given, names the derived channels whose
|
||||
/// rings may be written -- `nullptr` writes all of them, which is what the producer does; a recompute
|
||||
/// passes the channels a graph change actually affects, so the others keep the samples they have.
|
||||
void math_apply_locked(const std::vector<f32>& sample, const std::vector<bool>* write_mask, bool timed);
|
||||
|
||||
/// Recomputes the derived channels in `affected` from the samples the store already holds: their rings are
|
||||
/// rebuilt from scratch, and the channels a chart change does *not* touch are left exactly as they were.
|
||||
void recompute_math_locked(const std::vector<bool>& affected);
|
||||
|
||||
/// How many slots the store currently holds. A file view reads it to tell a structural change that
|
||||
/// dropped the buffer (0) from one that could not have (see `reload_file_view()` in ui.cpp).
|
||||
std::uint64_t stored_slots();
|
||||
|
||||
/// Pushes one stored slot of a **file** load -- the values, the shared clock and the absolute
|
||||
/// clock -- and then the derived channels, in the producer's own order and under the model's own
|
||||
/// lock. The loader cannot simply hold the lock and push into `live()` itself: every setter it
|
||||
/// needs (`set_signals`, `set_signal_enabled`, ...) takes that same non-recursive lock, so the
|
||||
/// outer one deadlocks the first call.
|
||||
void push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t);
|
||||
/// What the derived channels cost the *producer*: every stored slot that went through
|
||||
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
|
||||
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
|
||||
@@ -224,6 +315,37 @@ class ScopeModel {
|
||||
std::uint64_t math_cost_ns() const noexcept {
|
||||
return math_cost_ns_.load(std::memory_order_relaxed);
|
||||
}
|
||||
/// What freezing a view costs, and *which thread it costs it to*. The two publishes are stalls on
|
||||
/// opposite sides of `mutex()` and so are counted apart: `publish_event()` runs on the **producer**
|
||||
/// with the lock already held, so its cost lands on the UI as a long tick (the documented `tick`
|
||||
/// max); `publish_pause_view()` runs on the **UI** thread and takes the lock, so its cost lands on
|
||||
/// the producer and can only surface as `gaps`. Neither is attributable in a frame mean, which is
|
||||
/// why they are counted rather than inferred. Each counter is written by one thread and read by the
|
||||
/// dump, so a relaxed store of a value already in hand is enough.
|
||||
///
|
||||
/// This is the number to judge a second live ring against: freezing a whole `BufferStore` would
|
||||
/// make both figures zero, since a `ChannelBuffer` maintains its pyramid incrementally and there
|
||||
/// would be nothing to copy -- but it costs a second budget's worth of memory and a second
|
||||
/// per-sample write on the producer, so it is worth paying only if the worst figures here are
|
||||
/// large enough to matter.
|
||||
std::uint64_t publish_event_count() const noexcept {
|
||||
return publish_event_count_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_event_us_total() const noexcept {
|
||||
return publish_event_us_total_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_event_us_worst() const noexcept {
|
||||
return publish_event_us_worst_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_count() const noexcept {
|
||||
return publish_pause_count_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_us_total() const noexcept {
|
||||
return publish_pause_us_total_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_us_worst() const noexcept {
|
||||
return publish_pause_us_worst_.load(std::memory_order_relaxed);
|
||||
}
|
||||
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||
|
||||
@@ -241,6 +363,12 @@ class ScopeModel {
|
||||
/// CONFIG, is not silently dropped.
|
||||
void set_calibration(std::size_t signal, double gain, double offset);
|
||||
|
||||
/// The calibration that *will* be applied to that signal, as `{gain, offset}` -- the identity
|
||||
/// `{1, 0}` for a signal nobody calibrated. The read counterpart of `set_calibration()`, and read
|
||||
/// from the model's own record rather than from the channels: a signal that is not acquired has
|
||||
/// no channel to read it from, and the record is what a dialog must show.
|
||||
std::pair<double, double> calibration(std::size_t signal);
|
||||
|
||||
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||
const Partition& partition() const noexcept { return part_; }
|
||||
|
||||
@@ -254,19 +382,18 @@ class ScopeModel {
|
||||
/// is armed.
|
||||
BufferStore& live();
|
||||
|
||||
/// Forgets the live buffer's history: every ring is emptied and every channel's
|
||||
/// published window is dropped, so the two stay index-aligned. Used when the
|
||||
/// acquisition has to change time source (see `Acquisition::build_stamps`): the
|
||||
/// stamps already recorded are on the old timeline, and a ring holding two
|
||||
/// timelines is not a time base at all -- the x axis, the crosshair and an export
|
||||
/// all read it by index.
|
||||
///
|
||||
/// Takes `mutex()` itself: call it from the GTK thread. The acquisition thread
|
||||
/// calls `reset_live_history_locked()` instead, already holding the lock (the
|
||||
/// same split `publish_event()` follows).
|
||||
void reset_live_history();
|
||||
/// The body of the above for a caller that already holds `mutex()`.
|
||||
void reset_live_history_locked();
|
||||
/// Records a reserved hole. Caller holds `mutex()` (the producer already does). The table
|
||||
/// is capped at `kMaxHoles`; past that the oldest is dropped, because an unbounded list of
|
||||
/// gaps is a leak and the oldest gap is the one furthest from being backfilled.
|
||||
static constexpr std::size_t kMaxHoles = 64;
|
||||
void note_hole(const Hole& h);
|
||||
const std::vector<Hole>& holes() const noexcept { return holes_; }
|
||||
/// The producer's view of the same table. `Acquisition::backfill_from()` marks a hole's packet
|
||||
/// filled as it repairs it, and that mark is what the band's fainter tint reads. Caller holds
|
||||
/// `mutex()` -- the same requirement `note_hole()` above states, and the producer already does.
|
||||
std::vector<Hole>& holes() noexcept { return holes_; }
|
||||
/// Drops holes whose time-ring slots the ring has already discarded.
|
||||
void forget_holes_before(u64 slot);
|
||||
|
||||
/// Builds the captured event from the slice of the live buffer covering
|
||||
/// `[edge - pre_s, edge + post_s]` in stream seconds, and publishes it atomically.
|
||||
@@ -450,6 +577,14 @@ class ScopeModel {
|
||||
/// Rebuilds buffers from the current channel list. Caller holds `mutex()`.
|
||||
void rebuild_locked();
|
||||
|
||||
/// Counts buffer re-plans: every `rebuild_locked()` bumps it. The producer caches the value
|
||||
/// it last saw and resets the state tied to the old store when it moves (see
|
||||
/// `Acquisition::on_data`). Not an atomic on purpose: `rebuild_locked()` only runs under
|
||||
/// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the
|
||||
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
|
||||
/// stride/channel-count comparison does not.
|
||||
unsigned plan_generation() const noexcept { return plan_generation_; }
|
||||
|
||||
private:
|
||||
void rebuild_channels();
|
||||
/// Re-appends one synthetic signal per math channel, after the stream's own. Called under the
|
||||
@@ -491,10 +626,29 @@ class ScopeModel {
|
||||
std::atomic<std::uint64_t> math_slots_{0};
|
||||
std::atomic<std::uint64_t> math_timed_{0};
|
||||
std::atomic<std::uint64_t> math_cost_ns_{0};
|
||||
/// See `publish_event_count()`. Single-writer each: the first three are the producer's, the last
|
||||
/// three the UI thread's.
|
||||
std::atomic<std::uint64_t> publish_event_count_{0};
|
||||
std::atomic<std::uint64_t> publish_event_us_total_{0};
|
||||
std::atomic<std::uint64_t> publish_event_us_worst_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_count_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_us_total_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_us_worst_{0};
|
||||
/// Fold one publish's elapsed microseconds into a counter triple. Called by the one thread that
|
||||
/// owns the triple, so the load/store pairs need no read-modify-write.
|
||||
static void note_publish(std::atomic<std::uint64_t>& count, std::atomic<std::uint64_t>& total,
|
||||
std::atomic<std::uint64_t>& worst, std::uint64_t us) noexcept {
|
||||
count.store(count.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||
total.store(total.load(std::memory_order_relaxed) + us, std::memory_order_relaxed);
|
||||
if (us > worst.load(std::memory_order_relaxed)) worst.store(us, std::memory_order_relaxed);
|
||||
}
|
||||
std::vector<bool> enabled_;
|
||||
|
||||
Partition part_;
|
||||
std::unique_ptr<BufferStore> store_;
|
||||
std::vector<Hole> holes_;
|
||||
/// See `plan_generation()`. Written and read under `mutex()` only.
|
||||
unsigned plan_generation_ = 0;
|
||||
TriggerConfig trigger_{};
|
||||
StreamStats stats_{};
|
||||
f64 epoch_ = 0.0;
|
||||
|
||||
+135
-2
@@ -31,6 +31,14 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
||||
/// mistaken for something the user placed.
|
||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||
/// A found hit's colour: the trigger marker's claim ("the scope fired here") in a cooler hue,
|
||||
/// because a hit is the same condition found in *history* and the two are on screen together --
|
||||
/// the live marker's red must stay the one that means "this is the event the capture is about".
|
||||
constexpr plot::u32 kSearchColor = 0x7FB2FFu;
|
||||
/// The *selected* hit's colour: the one the arrows are standing on, and the one a pause was taken
|
||||
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
|
||||
/// picture that cannot be read, so the current hit is brighter than its neighbours.
|
||||
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
|
||||
|
||||
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||
@@ -62,6 +70,23 @@ constexpr plot::f32 kRowGap = 6.0f;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string layer_base_name(const std::string& name) {
|
||||
const std::size_t a = name.rfind(" (");
|
||||
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
|
||||
name[a + 2] >= 'A' && name[a + 2] <= 'B') {
|
||||
return name.substr(0, a);
|
||||
}
|
||||
const std::size_t h = name.rfind(" #");
|
||||
if (h != std::string::npos && h + 2 < name.size()) {
|
||||
bool digits = true;
|
||||
for (std::size_t i = h + 2; i < name.size(); ++i) {
|
||||
if (name[i] < '0' || name[i] > '9') digits = false;
|
||||
}
|
||||
if (digits) return name.substr(0, h);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
plot::Color RowSet::palette_color(int index) {
|
||||
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
|
||||
return plot::Color::hex(kPalette[i]);
|
||||
@@ -69,6 +94,43 @@ plot::Color RowSet::palette_color(int index) {
|
||||
|
||||
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||
|
||||
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
|
||||
// The exact name first, then the signal's: a colour the user picked for `sine` is the colour of
|
||||
// every layer of it, so a comparison's `sine (B)` is not a signal of its own colour.
|
||||
auto it = signal_colors_.find(signal);
|
||||
if (it == signal_colors_.end()) it = signal_colors_.find(layer_base_name(signal));
|
||||
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
|
||||
return palette_color(palette_index);
|
||||
}
|
||||
|
||||
void RowSet::mark_signal_dirty(const std::string& signal) {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
const Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
|
||||
row_dirty_[static_cast<std::size_t>(i)] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
|
||||
if (signal.empty()) return;
|
||||
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
|
||||
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
|
||||
// by the plot and silently written to the session file.
|
||||
const plot::u32 want = rgb & 0x00FFFFFFu;
|
||||
const auto it = signal_colors_.find(signal);
|
||||
if (it != signal_colors_.end() && it->second == want) return;
|
||||
signal_colors_[signal] = want;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
void RowSet::clear_signal_color(const std::string& signal) {
|
||||
if (signal_colors_.erase(signal) == 0) return;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
RowSet::RowSet() { rebuild_all(); }
|
||||
|
||||
void RowSet::set_row_count(int rows) {
|
||||
@@ -130,8 +192,8 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace(
|
||||
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
|
||||
r.axis_x, r.axis_y, r.name[i]});
|
||||
}
|
||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||
@@ -168,6 +230,15 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||
}
|
||||
// The search hits' pool, parked, created for the same reason the measurement pool above is:
|
||||
// `add_cursor()` only appends, so every mark a scan could ever want exists from the start.
|
||||
for (int m = 0; m < kSearchMarks; ++m) {
|
||||
const std::size_t mi = static_cast<std::size_t>(m);
|
||||
r.search_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kSearchColor));
|
||||
r.plot->set_cursor_selectable(r.search_v[mi], false);
|
||||
r.plot->set_cursor_stroke(r.search_v[mi], kMeasureStrokeWidth,
|
||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||
}
|
||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||
// snap is Free and the controller never rounds it onto a sample.
|
||||
@@ -181,9 +252,23 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
||||
}
|
||||
|
||||
void RowSet::set_row_weights(std::vector<float> weights) {
|
||||
// The count is not checked here: `apply_row_weights()` sizes it to the rows that exist, so a layout
|
||||
// with rows added or removed keeps working instead of silently losing its sizing.
|
||||
row_weights_ = std::move(weights);
|
||||
if (figure_ != nullptr) apply_row_weights();
|
||||
}
|
||||
|
||||
void RowSet::apply_row_weights() {
|
||||
if (row_weights_.empty() || figure_ == nullptr) return;
|
||||
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
|
||||
(void)figure_->set_row_weights(row_weights_);
|
||||
}
|
||||
|
||||
void RowSet::rebuild_all() {
|
||||
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
||||
figure_->set_row_gap(kRowGap);
|
||||
apply_row_weights();
|
||||
x_group_ = figure_->add_x_link();
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
@@ -320,6 +405,25 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_invalid_spans(ScopeModel& model, DisplayMode mode) {
|
||||
spans_.clear();
|
||||
if (mode == DisplayMode::Live || mode == DisplayMode::Capturing) {
|
||||
// `Hole::x_begin`/`x_end` are stream seconds; the plots' x is `stream_t + epoch`.
|
||||
const f64 epoch = model.display_epoch();
|
||||
for (const Hole& h : model.holes()) {
|
||||
// `repaired()` and not a second list: the band keeps reporting a repaired hole, in the
|
||||
// fainter tint, until its slots age out of the ring and `forget_holes_before()` drops it.
|
||||
spans_.push_back(plot::InvalidSpan{h.x_begin + epoch, h.x_end + epoch, h.repaired()});
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
// Every row shares the one x window, so every row gets the same spans: a hole is a gap in
|
||||
// the stream and not in one signal.
|
||||
if (r.plot) r.plot->set_invalid_spans(spans_);
|
||||
}
|
||||
}
|
||||
|
||||
bool RowSet::live_edge(double& newest) const {
|
||||
bool any = false;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
@@ -510,6 +614,32 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_search_marks(const std::vector<double>& xs, int current) {
|
||||
const int n = static_cast<int>(xs.size()) < kSearchMarks ? static_cast<int>(xs.size())
|
||||
: kSearchMarks;
|
||||
search_marks_placed_ = n;
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
// Every row, not the hit's own row: a hit is an *instant on the shared x window*, so the
|
||||
// mark lines up down the column the way the vertical cursors do. There is no "the hit's
|
||||
// row" to pick -- the condition was found on the trigger's channel, which may not be
|
||||
// plotted at all.
|
||||
for (int m = 0; m < n; ++m) {
|
||||
const std::size_t mi = static_cast<std::size_t>(m);
|
||||
r.plot->set_cursor_x(r.search_v[mi], xs[mi], plot::CursorPlacement::Free);
|
||||
// The colour is re-set every frame rather than only when the selection moves: the pool is
|
||||
// re-ordered each frame as the view rolls, so slot `m` is not the same hit it was.
|
||||
r.plot->set_cursor_color(r.search_v[mi],
|
||||
plot::Color::hex(m == current ? kSearchCurrentColor
|
||||
: kSearchColor));
|
||||
}
|
||||
for (int m = n; m < kSearchMarks; ++m) {
|
||||
r.plot->set_cursor_x(r.search_v[static_cast<std::size_t>(m)], kParked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool RowSet::update_time_labels() {
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return false;
|
||||
@@ -663,6 +793,9 @@ bool RowSet::readouts_locked() const noexcept {
|
||||
for (const plot::u32 id : r.measure_v) {
|
||||
if (r.plot->cursor(id).selectable) return false;
|
||||
}
|
||||
for (const plot::u32 id : r.search_v) {
|
||||
if (r.plot->cursor(id).selectable) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+100
-1
@@ -9,8 +9,10 @@
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
@@ -39,6 +41,12 @@ namespace udpscope {
|
||||
/// count is fixed at construction, so there is one path for "a row's traces changed"
|
||||
/// and "the row count changed", and it costs no GL state -- the area keeps its
|
||||
/// context and is simply handed the new figure.
|
||||
/// The signal a channel name belongs to, with a group's layer suffix taken back off: `sine (A)`,
|
||||
/// `sine (B)` and `sine #2` are all the signal `sine`. A comparison's model holds one channel per
|
||||
/// (signal, layer), so this is what lets the signal list show each signal once, draw every layer of it
|
||||
/// in the signal's own colour, and stop a second group from re-suffixing a name a first one left.
|
||||
std::string layer_base_name(const std::string& name);
|
||||
|
||||
class RowSet {
|
||||
public:
|
||||
/// The ceiling on rows, not the count: rows are added and removed one at a time
|
||||
@@ -47,11 +55,20 @@ class RowSet {
|
||||
/// allocated somewhere, and because the figure keeps a floor height per row -- past
|
||||
/// about this many a row is chrome and no plot.
|
||||
static constexpr int kMaxRows = 8;
|
||||
static constexpr int kSlotsPerRow = 4;
|
||||
/// Traces one row can hold. Eight rather than the four a hand-arranged scope usually wants, because
|
||||
/// the *default* layout is four signals in one row and an accumulation needs a slot per extra event:
|
||||
/// with four, "accumulate these two events" over the layout the app starts with had nowhere to put
|
||||
/// the second copy and silently did nothing. The free slots cost nothing -- a `Plot` creates its
|
||||
/// traces up front either way -- and the user's own traces stay where they are.
|
||||
static constexpr int kSlotsPerRow = 8;
|
||||
/// Readout marks per row per orientation: enough for a handful of level and time
|
||||
/// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor
|
||||
/// set at construction.
|
||||
static constexpr int kMeasureMarks = 6;
|
||||
/// Search hits drawn at once, per row. A scan can return thousands; a plot cannot grow its
|
||||
/// cursor set, and sixteen lines over one window is already the most a reader can count. The
|
||||
/// panel states how many of the hits it is showing.
|
||||
static constexpr int kSearchMarks = 16;
|
||||
|
||||
RowSet();
|
||||
|
||||
@@ -111,6 +128,10 @@ class RowSet {
|
||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||
std::array<plot::u32, kMeasureMarks> measure_v{};
|
||||
|
||||
/// The search hits' marks: the same arrangement as the measurement pool above, vertical only
|
||||
/// (a hit is an instant, never a level).
|
||||
std::array<plot::u32, kSearchMarks> search_v{};
|
||||
|
||||
/// One marker per trace per cursor: where each vertical cursor crosses that trace's
|
||||
/// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace
|
||||
/// at two places, and an annotation cannot be shared between them.
|
||||
@@ -125,6 +146,22 @@ class RowSet {
|
||||
};
|
||||
|
||||
int row_count() const noexcept { return rows_; }
|
||||
|
||||
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
|
||||
/// traces translucent so that agreement between them reads as brightness, which is the whole point
|
||||
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
|
||||
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
|
||||
/// the previous view left behind.
|
||||
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
|
||||
|
||||
/// The rows' shares of the figure's height, normalised by the plotter; an empty vector means equal
|
||||
/// rows. Kept here, and re-applied after every rebuild: a `Figure` is built afresh whenever a row's
|
||||
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
|
||||
/// first drop or row change after a user had sized their layout.
|
||||
void set_row_weights(std::vector<float> weights);
|
||||
/// Sizes the stored weights to the rows that exist and hands them to the figure.
|
||||
void apply_row_weights();
|
||||
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
|
||||
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||
/// count is fixed at construction.
|
||||
void set_row_count(int rows);
|
||||
@@ -170,6 +207,18 @@ class RowSet {
|
||||
/// and status line all describe the same display: the producer can open or close a
|
||||
/// capture between any two reads of `ScopeModel::display_mode()`.
|
||||
void bind(ScopeModel& model, DisplayMode mode);
|
||||
/// Publishes `model.holes()` to every row as shading, in display coordinates.
|
||||
///
|
||||
/// Separate from `bind()` and called *after* the display epoch has been written, because the
|
||||
/// epoch the band needs is this frame's: `ui.cpp` recomputes it from the newly bound traces'
|
||||
/// live edge, and the previous frame's value is a whole production interval stale -- a third
|
||||
/// of the screen at 1 MS/s over a 50 ms window, which would put the band beside the gap
|
||||
/// rather than over it.
|
||||
///
|
||||
/// Shading only in `Live` and `Capturing`. A `Held` or `Paused` snapshot carries its own
|
||||
/// stamps and its own x offset, and the live hole table does not address it; shading it would
|
||||
/// accuse whichever samples happened to land under those coordinates.
|
||||
void set_invalid_spans(ScopeModel& model, DisplayMode mode);
|
||||
/// Publishes the live buffer's new samples.
|
||||
void acquire(ScopeModel& model) { model.acquire_all(); }
|
||||
|
||||
@@ -254,6 +303,17 @@ class RowSet {
|
||||
/// measurement is drawn", as opposed to merely computed.
|
||||
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
|
||||
|
||||
/// Places the search hits' marks and parks the rest of the pool. `xs` are **display** x values
|
||||
/// (the caller has already taken `stream_at_x0()` off a stream time), the same convention
|
||||
/// `set_measure_marks()` takes a time mark in, and the marks are unselectable for the same
|
||||
/// reason: a hit is where the scope found the condition, not something the pointer may move.
|
||||
///
|
||||
/// `current` indexes the one the navigation is standing on, which is drawn in its own colour, or
|
||||
/// is negative for none. The caller orders `xs`; this places them in the order it is given.
|
||||
void set_search_marks(const std::vector<double>& xs, int current);
|
||||
/// How many search marks the last call placed, which is `min(xs.size(), kSearchMarks)`.
|
||||
int search_marks_placed() const noexcept { return search_marks_placed_; }
|
||||
|
||||
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
|
||||
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
|
||||
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
|
||||
@@ -325,6 +385,13 @@ class RowSet {
|
||||
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||
bool window_pinned() const noexcept { return pinned_; }
|
||||
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
|
||||
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
|
||||
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
|
||||
/// undo a gesture that `hold_pinned_window()` has not adopted yet.
|
||||
bool pinned_window_is(double pre_s, double post_s) const noexcept {
|
||||
return pinned_ && pinned_pre_ == pre_s && pinned_post_ == post_s;
|
||||
}
|
||||
|
||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||
/// a cursor), and nothing otherwise.
|
||||
@@ -399,7 +466,30 @@ class RowSet {
|
||||
/// names beside it shifted sideways as signals were placed.
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
|
||||
/// positional palette entry otherwise.
|
||||
///
|
||||
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
|
||||
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
|
||||
/// a colour by a different route than the trace is the single failure the chip cannot
|
||||
/// survive -- and an override is exactly the state in which two routes would disagree.
|
||||
plot::Color signal_color(const std::string& signal, int palette_index) const;
|
||||
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
|
||||
/// override held by index would move to another signal on the next CONFIG. Marks every row that
|
||||
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
|
||||
/// figure, which is the same path `assign()` already takes.
|
||||
void set_signal_color(const std::string& signal, plot::u32 rgb);
|
||||
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
|
||||
void clear_signal_color(const std::string& signal);
|
||||
/// Every override, for the session file. Ordered by name, so a saved session's colour
|
||||
/// tables do not reshuffle between writes.
|
||||
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
|
||||
|
||||
private:
|
||||
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
|
||||
/// the slot *holds* changed, only the colour its trace has to be built with.
|
||||
void mark_signal_dirty(const std::string& signal);
|
||||
|
||||
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
|
||||
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
|
||||
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
|
||||
@@ -422,6 +512,8 @@ class RowSet {
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
plot::u32 x_group_ = 0;
|
||||
int rows_ = 1;
|
||||
float trace_alpha_ = 1.0f;
|
||||
std::vector<float> row_weights_;
|
||||
double window_s_ = 2.0;
|
||||
double trigger_marker_x_ = 0.0;
|
||||
bool trigger_marker_on_ = false;
|
||||
@@ -440,6 +532,12 @@ class RowSet {
|
||||
double right_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
|
||||
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
|
||||
/// coloured without anything having to seed them.
|
||||
std::map<std::string, plot::u32> signal_colors_;
|
||||
/// Reused across frames by `set_invalid_spans()`: see there.
|
||||
std::vector<plot::InvalidSpan> spans_;
|
||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||
bool cursors_on_ = false;
|
||||
bool relative_x_ = false;
|
||||
@@ -451,6 +549,7 @@ class RowSet {
|
||||
/// back, one row's stale echo at a time.
|
||||
|
||||
int measure_marks_placed_ = 0;
|
||||
int search_marks_placed_ = 0;
|
||||
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
|
||||
double cursor_a_x_ = 0.0;
|
||||
double cursor_b_x_ = 0.0;
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "search.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
namespace {
|
||||
double ms_since(const std::chrono::steady_clock::time_point& t0) {
|
||||
const std::chrono::duration<double, std::milli> d = std::chrono::steady_clock::now() - t0;
|
||||
return d.count();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BufferSearch::start(const EventCondition& c, int channel) {
|
||||
clear();
|
||||
channel_ = channel;
|
||||
detector_.reset();
|
||||
detector_.configure(c);
|
||||
// `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
|
||||
// makes a re-run of the *same* search start from a clean machine.
|
||||
detector_.reset();
|
||||
state_ = State::Running;
|
||||
t_scratch_.resize(static_cast<std::size_t>(kChunk));
|
||||
v_scratch_.resize(static_cast<std::size_t>(kChunk));
|
||||
}
|
||||
|
||||
void BufferSearch::cancel() noexcept {
|
||||
if (state_ == State::Running) state_ = State::Cancelled;
|
||||
}
|
||||
|
||||
void BufferSearch::clear() noexcept {
|
||||
state_ = State::Idle;
|
||||
started_ = false;
|
||||
truncated_ = false;
|
||||
at_ = 0;
|
||||
end_ = 0;
|
||||
first_s_ = 0.0;
|
||||
scanned_s_ = 0.0;
|
||||
total_s_ = 0.0;
|
||||
hits_.clear();
|
||||
cursor_ = -1;
|
||||
chunks_ = 0;
|
||||
worst_chunk_ms_ = 0.0;
|
||||
total_ms_ = 0.0;
|
||||
}
|
||||
|
||||
void BufferSearch::select(int i) noexcept {
|
||||
if (hits_.empty()) {
|
||||
cursor_ = -1;
|
||||
return;
|
||||
}
|
||||
if (i < 0) i = 0;
|
||||
if (static_cast<std::size_t>(i) >= hits_.size()) i = static_cast<int>(hits_.size()) - 1;
|
||||
cursor_ = i;
|
||||
}
|
||||
|
||||
bool BufferSearch::next() noexcept {
|
||||
if (hits_.empty() || cursor_ + 1 >= static_cast<int>(hits_.size())) return false;
|
||||
++cursor_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BufferSearch::prev() noexcept {
|
||||
if (hits_.empty() || cursor_ <= 0) return false;
|
||||
--cursor_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BufferSearch::pump(ScopeModel& model) {
|
||||
if (state_ != State::Running) return false;
|
||||
const auto chunk_t0 = std::chrono::steady_clock::now();
|
||||
u64 n = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
if (channel_ < 0 || static_cast<std::size_t>(channel_) >= model.channel_count()) {
|
||||
state_ = State::Cancelled;
|
||||
return false;
|
||||
}
|
||||
BufferStore& store = model.live();
|
||||
if (!started_) {
|
||||
// The whole retained buffer: the bounds are deliberately absurd, because
|
||||
// `live_window_locked()` clamps them to the time ring and to *every* value ring, which
|
||||
// is the one cut that keeps the stamps and the values index-aligned by construction.
|
||||
u64 first = 0;
|
||||
u64 last = 0;
|
||||
model.live_window_locked(-1.0e300, 1.0e300, first, last);
|
||||
at_ = first;
|
||||
end_ = last;
|
||||
started_ = true;
|
||||
if (end_ > at_) {
|
||||
f64 stamp = 0.0;
|
||||
store.time().copy_from(at_, 1, &stamp);
|
||||
first_s_ = static_cast<double>(stamp);
|
||||
store.time().copy_from(end_ - 1, 1, &stamp);
|
||||
total_s_ = static_cast<double>(stamp) - first_s_;
|
||||
}
|
||||
}
|
||||
// The producer keeps pushing while we scan, so the oldest samples go. `copy_from()` fixes
|
||||
// *which* indices are read, not that the slots still hold them -- a read behind the base
|
||||
// would silently return whatever overwrote it, which would be a hit at a time that never
|
||||
// existed. Skipping forward and reporting `Truncated` is the honest answer.
|
||||
const u64 base = store.time().base();
|
||||
if (base > at_) {
|
||||
at_ = base;
|
||||
truncated_ = true;
|
||||
detector_.reset(); // the sequence has a gap in it; no pulse spans it
|
||||
}
|
||||
if (at_ >= end_) {
|
||||
state_ = truncated_ ? State::Truncated : State::Done;
|
||||
total_ms_ += ms_since(chunk_t0);
|
||||
return false;
|
||||
}
|
||||
n = end_ - at_ < kChunk ? end_ - at_ : kChunk;
|
||||
store.time().copy_from(at_, n, t_scratch_.data());
|
||||
store.values(static_cast<std::size_t>(channel_)).copy_from(at_, n, v_scratch_.data());
|
||||
}
|
||||
// The evaluation is outside the lock: it is the expensive half, and it touches only the
|
||||
// copies above.
|
||||
for (u64 i = 0; i < n; ++i) {
|
||||
const double t = static_cast<double>(t_scratch_[static_cast<std::size_t>(i)]);
|
||||
const double e =
|
||||
detector_.feed(static_cast<double>(v_scratch_[static_cast<std::size_t>(i)]), t);
|
||||
if (std::isfinite(e)) {
|
||||
hits_.push_back(e);
|
||||
if (hits_.size() >= kHitsMax) {
|
||||
at_ += i + 1;
|
||||
scanned_s_ = t - first_s_;
|
||||
state_ = State::Capped;
|
||||
++chunks_;
|
||||
const double cost = ms_since(chunk_t0);
|
||||
if (cost > worst_chunk_ms_) worst_chunk_ms_ = cost;
|
||||
total_ms_ += cost;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
scanned_s_ = t - first_s_;
|
||||
}
|
||||
at_ += n;
|
||||
++chunks_;
|
||||
const double cost = ms_since(chunk_t0);
|
||||
if (cost > worst_chunk_ms_) worst_chunk_ms_ = cost;
|
||||
total_ms_ += cost;
|
||||
if (at_ >= end_) {
|
||||
state_ = truncated_ ? State::Truncated : State::Done;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "condition.hpp"
|
||||
#include "model.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// A scan of the whole retained buffer for every sample satisfying an `EventCondition`.
|
||||
///
|
||||
/// It runs an `EventDetector` -- the *same* evaluator the live trigger runs -- over the ring
|
||||
/// in stream order, so a hit is by construction an instant the trigger would have fired on.
|
||||
/// That is the claim the probe's armed-vs-scanned cross-check checks, and it is the reason
|
||||
/// the detector was separated from the engine at all.
|
||||
///
|
||||
/// Everything here is in **ring-stamp space**, which is stream seconds relative to the
|
||||
/// model's epoch: the clock `TriggerEngine::process()` is fed, the clock
|
||||
/// `ScopeModel::live_window_locked()` searches, and the clock `publish_pause_view()` cuts in.
|
||||
/// No conversion to display x happens in this class.
|
||||
///
|
||||
/// It runs on the GTK thread in chunks rather than on a thread of its own: the samples live
|
||||
/// behind `ScopeModel::mutex()`, which the producer holds for each packet, and a scan that
|
||||
/// took the lock once would stall acquisition for the whole buffer. One chunk per call holds
|
||||
/// it for `kChunk` samples and no longer.
|
||||
class BufferSearch {
|
||||
public:
|
||||
/// The hit list's ceiling. A condition matching a large share of the samples -- an edge
|
||||
/// trigger on a noisy channel -- would otherwise fill memory with a list nobody can
|
||||
/// navigate; the scan stops and says `Capped`, which is information rather than a hang.
|
||||
static constexpr std::size_t kHitsMax = 10000;
|
||||
/// Samples per `pump()`, and therefore the only latency knob: the producer waits on the
|
||||
/// lock for exactly one chunk's scan.
|
||||
static constexpr u64 kChunk = 8192;
|
||||
|
||||
enum class State {
|
||||
Idle, ///< nothing has been searched.
|
||||
Running, ///< `pump()` has more to do.
|
||||
Done, ///< the whole retained buffer was scanned.
|
||||
Truncated, ///< the producer overwrote samples before they were reached.
|
||||
Capped, ///< `kHitsMax` was reached.
|
||||
Cancelled, ///< the user stopped it, or the channel went away.
|
||||
};
|
||||
|
||||
/// Begins a scan of the channel for the condition. Discards any previous result.
|
||||
void start(const EventCondition& c, int channel);
|
||||
void cancel() noexcept;
|
||||
void clear() noexcept;
|
||||
|
||||
/// Scans one chunk. Returns true while there is more to do. Takes `model.mutex()` itself,
|
||||
/// so the caller must *not* hold it.
|
||||
bool pump(ScopeModel& model);
|
||||
|
||||
State state() const noexcept { return state_; }
|
||||
const std::vector<double>& hits() const noexcept { return hits_; }
|
||||
/// Which hit the view is on, or -1 for none.
|
||||
int cursor() const noexcept { return cursor_; }
|
||||
void select(int i) noexcept;
|
||||
/// Moves the cursor and returns whether it moved; the ends do not wrap, because a wrap
|
||||
/// from the last hit to the first looks exactly like the view not having moved at all.
|
||||
bool next() noexcept;
|
||||
bool prev() noexcept;
|
||||
|
||||
/// How much of the buffer has been scanned, and how much there was, in stream seconds.
|
||||
double scanned_s() const noexcept { return scanned_s_; }
|
||||
double total_s() const noexcept { return total_s_; }
|
||||
/// Cost, for the probe: chunks scanned, the worst single chunk, and the whole scan.
|
||||
std::uint64_t chunks() const noexcept { return chunks_; }
|
||||
double worst_chunk_ms() const noexcept { return worst_chunk_ms_; }
|
||||
double total_ms() const noexcept { return total_ms_; }
|
||||
|
||||
private:
|
||||
EventDetector detector_{};
|
||||
int channel_ = -1;
|
||||
State state_ = State::Idle;
|
||||
bool started_ = false; ///< the index range has been established.
|
||||
bool truncated_ = false; ///< the producer overtook the scan at least once; sticky for the whole scan.
|
||||
u64 at_ = 0; ///< next absolute index to scan.
|
||||
u64 end_ = 0; ///< one past the last absolute index to scan.
|
||||
double first_s_ = 0.0; ///< stamp of `at_` when the scan began.
|
||||
double scanned_s_ = 0.0;
|
||||
double total_s_ = 0.0;
|
||||
std::vector<double> hits_;
|
||||
int cursor_ = -1;
|
||||
std::uint64_t chunks_ = 0;
|
||||
double worst_chunk_ms_ = 0.0;
|
||||
double total_ms_ = 0.0;
|
||||
std::vector<f64> t_scratch_;
|
||||
std::vector<f32> v_scratch_;
|
||||
};
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "settings.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#include "layout_store.hpp" // `mkdirs()`, so a first run's config directory is created with parents
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// One `key = value` per line, `#` for a comment: the same shape the layout files use, and parsed just
|
||||
/// as leniently -- an unknown key, an empty line or a file written by an older version is not an error.
|
||||
bool parse_bool(const std::string& v, bool fallback) {
|
||||
if (v == "true" || v == "1" || v == "yes") return true;
|
||||
if (v == "false" || v == "0" || v == "no") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
std::string unquote(const std::string& v) {
|
||||
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') return v.substr(1, v.size() - 2);
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Settings default_settings() { return Settings{}; }
|
||||
|
||||
std::string settings_path() {
|
||||
const std::string dir = std::string(g_get_user_config_dir()) + "/udpscope";
|
||||
mkdirs(dir); // every component: `<config>` itself may not exist on a fresh install
|
||||
return dir + "/settings.toml";
|
||||
}
|
||||
|
||||
Settings load_settings() {
|
||||
Settings s; // the defaults, so the file only has to mention what a user changed
|
||||
std::ifstream in(settings_path());
|
||||
if (!in) return s;
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
const std::size_t hash = line.find('#');
|
||||
if (hash != std::string::npos) line = line.substr(0, hash);
|
||||
const std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string value = line.substr(eq + 1);
|
||||
const auto trim = [](std::string& t) {
|
||||
const std::size_t a = t.find_first_not_of(" \t\r\n");
|
||||
const std::size_t b = t.find_last_not_of(" \t\r\n");
|
||||
t = a == std::string::npos ? std::string() : t.substr(a, b - a + 1);
|
||||
};
|
||||
trim(key);
|
||||
trim(value);
|
||||
value = unquote(value);
|
||||
if (value.empty()) continue;
|
||||
if (key == "event_cache_dir") {
|
||||
s.event_cache_dir = value;
|
||||
} else if (key == "event_cache_max") {
|
||||
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
|
||||
} else if (key == "relative_time") {
|
||||
s.relative_time = parse_bool(value, s.relative_time);
|
||||
} else if (key == "theme") {
|
||||
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool save_settings(const Settings& settings) {
|
||||
std::ofstream out(settings_path(), std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out << "# udpscope preferences -- written by the preferences dialog, read at start-up.\n";
|
||||
out << "# A key that is absent means its default, so this file only has to mention what changed.\n";
|
||||
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
|
||||
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||
out << "theme = \"" << settings.theme << "\"\n";
|
||||
out.flush();
|
||||
return out.good();
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The preferences: what a user chose about this program rather than about a source, kept between runs
|
||||
// in one file. Deliberately free of GTK and of the app's own state -- it is a handful of values, a
|
||||
// file to read them from and a file to write them to -- so the dialog that edits them, the code that
|
||||
// applies them and the tests that read them all go through the same struct.
|
||||
|
||||
#ifndef UDPSCOPE_SETTINGS_HPP
|
||||
#define UDPSCOPE_SETTINGS_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Everything the preferences dialog can change.
|
||||
///
|
||||
/// The defaults are the *stated* ones: an absent file and a fresh install behave identically, so a
|
||||
/// setting is only ever a user's decision and never an accident of what a file happened to contain.
|
||||
struct Settings {
|
||||
/// Where the event cache goes: the directory each source's folder is created in. Empty means the
|
||||
/// standard `<cache>/udpscope/events`, and a path is used as given -- one directory for every
|
||||
/// source, never one between them, because two sources writing one directory is two sources
|
||||
/// overwriting each other's events.
|
||||
std::string event_cache_dir;
|
||||
/// How many events one source's cache keeps. 0 disables trimming, which is a decision about the
|
||||
/// user's disk made deliberately rather than by default.
|
||||
int event_cache_max = 32;
|
||||
/// `--relative-time`'s setting, for a source whose wall-clock stamps jitter. A *startup* setting: the
|
||||
/// buffers are planned before any stream is known, so it takes effect on the next start (the
|
||||
/// preferences dialog says so, like the configurations dialog does).
|
||||
bool relative_time = false;
|
||||
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
||||
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
||||
std::string theme = "auto";
|
||||
};
|
||||
|
||||
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
||||
/// what a missing file yields.
|
||||
Settings default_settings();
|
||||
|
||||
/// The file the settings live in: `<config>/udpscope/settings.toml`. Never empty.
|
||||
std::string settings_path();
|
||||
|
||||
/// Reads the settings, filling anything the file does not mention from the defaults. A file that
|
||||
/// cannot be read is not an error: it is a first run.
|
||||
Settings load_settings();
|
||||
|
||||
/// Writes them. Returns false when the file cannot be written, which the dialog reports rather than
|
||||
/// swallowing -- a preference that silently does not stick is worse than one that says it did not.
|
||||
bool save_settings(const Settings& settings);
|
||||
|
||||
} // namespace udpscope
|
||||
|
||||
#endif // UDPSCOPE_SETTINGS_HPP
|
||||
+6
-3
@@ -38,9 +38,11 @@ constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]
|
||||
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
|
||||
///
|
||||
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
|
||||
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
|
||||
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
|
||||
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
|
||||
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator syntax nor
|
||||
/// libadwaita's knows a wheel or a drag, and asking either to parse "Wheel" is a parse warning and an
|
||||
/// empty cell. The overview draws them from `src/ui/shortcuts.ui`, which gives each one GTK's generic
|
||||
/// gesture type and an icon where the keycaps would be. That file is the *presentation*; this table is
|
||||
/// what the application handles, which is why `[help]`'s `ui=` is checked against `tables=`.
|
||||
constexpr Gesture kGestures[] = {
|
||||
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
|
||||
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
|
||||
@@ -55,6 +57,7 @@ constexpr Gesture kGestures[] = {
|
||||
{"", "Drag", "Pan", "Plot mouse"},
|
||||
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
|
||||
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
|
||||
{"", "Shift and drag", "Box zoom on time and values", "Plot mouse"},
|
||||
};
|
||||
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
|
||||
|
||||
|
||||
+569
-109
@@ -3,11 +3,17 @@
|
||||
|
||||
#include "source_dialog.hpp"
|
||||
|
||||
#include "waveform.hpp"
|
||||
|
||||
#include "layout_store.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "ui_prefs.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
@@ -24,13 +30,25 @@ void apply_source(Config& cfg, const KnownSource& src) {
|
||||
cfg.port = src.port;
|
||||
cfg.multicast = src.multicast;
|
||||
cfg.interface_addr = src.interface_addr;
|
||||
// The configuration's own startup settings. They are read *before* the model exists, which is the
|
||||
// only moment a buffer size can be applied at all -- and the reason they are stored per
|
||||
// configuration rather than only passed on the command line.
|
||||
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
|
||||
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
|
||||
// open from this dialog a user can state relative time a minute before clicking the row, and that
|
||||
// statement must survive the click that follows it.
|
||||
if (!cfg.relative_time_specified) cfg.relative_time = src.relative_time;
|
||||
cfg.capture_packets = src.capture_packets;
|
||||
}
|
||||
|
||||
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
||||
void report_sources(const std::vector<KnownSource>& known) {
|
||||
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
||||
for (const KnownSource& s : known) {
|
||||
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
||||
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u",
|
||||
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
|
||||
s.relative_time ? 1 : 0, s.capture_packets);
|
||||
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
|
||||
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
||||
if (!s.interface_addr.empty()) {
|
||||
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
||||
@@ -51,20 +69,65 @@ struct Chooser {
|
||||
GtkWidget* window = nullptr;
|
||||
GtkWidget* list = nullptr;
|
||||
std::vector<KnownSource> known;
|
||||
/// The window's frame is a `.ui` file (`src/ui/source-chooser.ui`): the window, its header bar, the two
|
||||
/// pages and the buttons that are about the chooser rather than about a source. The builder is held
|
||||
/// with the window for the same reason the form's is -- the window belongs to the builder's object list.
|
||||
GtkBuilder* ui = nullptr;
|
||||
/// The Waveforms page: the exports opened before, newest first, and the stack that holds the two pages.
|
||||
/// A waveform is the *other* answer to "which source", so it gets a page of its own rather than a button
|
||||
/// that hides a file dialog behind one more click. A `GtkNotebook` was the first shape of this and a tab
|
||||
/// strip is the wrong one: an `AdwViewStack` under an `AdwViewSwitcherBar` is what current libadwaita
|
||||
/// asks for, and the switcher's stack is wired in code because a property cannot name a widget declared
|
||||
/// elsewhere in the same tree.
|
||||
GtkWidget* page_stack = nullptr;
|
||||
GtkWidget* switcher = nullptr;
|
||||
/// The buttons the header bar holds, kept because two of them are read at runtime: `new_host` takes the
|
||||
/// focus when the list is empty (there is nothing else to do), and all of them are read back by
|
||||
/// `report_chooser_ui()` so a `.ui` that lost one is named rather than silently missing its button.
|
||||
GtkWidget* prefs_button = nullptr;
|
||||
GtkWidget* new_host = nullptr;
|
||||
GtkWidget* waves_open = nullptr;
|
||||
GtkWidget* cancel_button = nullptr;
|
||||
GtkWidget* wave_list = nullptr;
|
||||
std::vector<std::string> waves;
|
||||
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
||||
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
||||
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
||||
/// -free in the math editor.
|
||||
GtkWidget* form = nullptr;
|
||||
GtkWidget* form_server = nullptr;
|
||||
GtkWidget* form_port = nullptr;
|
||||
GtkWidget* form_group = nullptr;
|
||||
GtkWidget* form_iface = nullptr;
|
||||
AdwDialog* form = nullptr;
|
||||
/// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
|
||||
/// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
|
||||
GtkBuilder* form_ui = nullptr;
|
||||
AdwWindowTitle* form_title = nullptr;
|
||||
AdwEntryRow* form_server = nullptr;
|
||||
AdwSpinRow* form_port = nullptr;
|
||||
AdwEntryRow* form_group = nullptr;
|
||||
AdwEntryRow* form_iface = nullptr;
|
||||
GtkWidget* form_connect = nullptr;
|
||||
/// The configuration half of the form: what a source's *session* is called and the settings that
|
||||
/// can only be applied before the model exists. A new host needs all of them, and so does an
|
||||
/// existing one -- which is why the same form is used for both.
|
||||
AdwEntryRow* form_name = nullptr;
|
||||
AdwEntryRow* form_note = nullptr;
|
||||
AdwSpinRow* form_buffer = nullptr;
|
||||
AdwSwitchRow* form_relative = nullptr;
|
||||
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
|
||||
/// because `chooser_populate()` rebuilds `known` under the form.
|
||||
std::string edit_host;
|
||||
std::uint16_t edit_port = 0;
|
||||
std::string edit_config;
|
||||
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
||||
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
||||
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
||||
guint pick_source = 0;
|
||||
/// What the *form* last wrote, for `report_pick()`: a save finishes the chooser and destroys the
|
||||
/// list with it, so the row a report would name is gone by the time the probe reads it.
|
||||
std::string saved_config;
|
||||
std::string saved_note;
|
||||
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
|
||||
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
|
||||
ui_impl::PrefsForm prefs;
|
||||
|
||||
};
|
||||
|
||||
void chooser_finish(Chooser& c, bool chosen) {
|
||||
@@ -75,28 +138,145 @@ void chooser_finish(Chooser& c, bool chosen) {
|
||||
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
||||
// window returns having done nothing.
|
||||
if (c.form != nullptr) {
|
||||
GtkWidget* w = c.form;
|
||||
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
AdwDialog* d = c.form;
|
||||
c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
|
||||
adw_dialog_force_close(d);
|
||||
}
|
||||
// The preferences go first: they are presented *on* this window, and a dialog still up when its
|
||||
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
|
||||
// `adw_dialog_close()`, and the form's own `closed` handler clears it.
|
||||
ui_impl::prefs_close(c.prefs);
|
||||
if (c.window != nullptr) {
|
||||
GtkWidget* w = c.window;
|
||||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
}
|
||||
// The window belonged to the builder's object list, so it is freed with it -- after the destroy, never
|
||||
// before: a builder freed under a live window takes the window's children with it.
|
||||
g_clear_object(&c.ui);
|
||||
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
||||
}
|
||||
|
||||
void chooser_populate(Chooser& c);
|
||||
void chooser_populate_waves(Chooser& c);
|
||||
void report_pick(const Chooser& c);
|
||||
/// Defined below; the row buttons reach it, and the row builder sits above it.
|
||||
void open_form(Chooser& c, int edit_index);
|
||||
|
||||
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
||||
const KnownSource& src = c.known[static_cast<std::size_t>(idx)];
|
||||
// Picking a configuration *is* choosing it as the active one: the app reads that back when it
|
||||
// loads the session, and the next save goes to the same file. Without this, opening `bench`
|
||||
// from the chooser would leave the source pointing at whatever was active before.
|
||||
set_active_configuration(src.host, src.port, src.config);
|
||||
apply_source(*c.cfg, src);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
|
||||
/// One row per waveform opened before, newest first. The name first (that is what a person reads),
|
||||
/// the directory under it, and a missing file said out loud rather than left to fail on the click.
|
||||
void chooser_populate_waves(Chooser& c) {
|
||||
while (GtkWidget* child = gtk_widget_get_first_child(c.wave_list)) {
|
||||
gtk_list_box_remove(GTK_LIST_BOX(c.wave_list), child);
|
||||
}
|
||||
c.waves = recent_waveforms();
|
||||
if (c.waves.empty()) {
|
||||
GtkWidget* empty = gtk_label_new("No waveform opened yet -- use Open\xe2\x80\xa6");
|
||||
gtk_widget_add_css_class(empty, "dim-label");
|
||||
gtk_widget_set_margin_top(empty, 24);
|
||||
gtk_widget_set_margin_bottom(empty, 24);
|
||||
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), empty);
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_NONE);
|
||||
return;
|
||||
}
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_SINGLE);
|
||||
for (std::size_t i = 0; i < c.waves.size(); ++i) {
|
||||
const std::string& path = c.waves[i];
|
||||
GtkWidget* row = gtk_list_box_row_new();
|
||||
g_object_set_data(G_OBJECT(row), "wave-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||
|
||||
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||||
gtk_widget_set_margin_start(line, 12);
|
||||
gtk_widget_set_margin_end(line, 6);
|
||||
gtk_widget_set_margin_top(line, 6);
|
||||
gtk_widget_set_margin_bottom(line, 6);
|
||||
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||
gtk_widget_set_hexpand(text, TRUE);
|
||||
const std::size_t slash = path.rfind('/');
|
||||
const std::string base = slash == std::string::npos ? path : path.substr(slash + 1);
|
||||
GtkWidget* title = gtk_label_new(base.c_str());
|
||||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(text), title);
|
||||
const bool present = ::access(path.c_str(), R_OK) == 0;
|
||||
std::string sub = present ? path : path + " (missing)";
|
||||
GtkWidget* under = gtk_label_new(sub.c_str());
|
||||
gtk_widget_add_css_class(under, "dim-label");
|
||||
gtk_widget_set_halign(under, GTK_ALIGN_START);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(under), PANGO_ELLIPSIZE_MIDDLE);
|
||||
gtk_box_append(GTK_BOX(text), under);
|
||||
gtk_box_append(GTK_BOX(line), text);
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), row);
|
||||
}
|
||||
}
|
||||
|
||||
void on_wave_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
if (row == nullptr) return;
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "wave-index"));
|
||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.waves.size()) return;
|
||||
const std::string& path = c.waves[static_cast<std::size_t>(idx)];
|
||||
if (::access(path.c_str(), R_OK) != 0) {
|
||||
std::fprintf(stderr, "[sources] waveform is gone: %s\n", path.c_str());
|
||||
std::fflush(stderr);
|
||||
return;
|
||||
}
|
||||
// The file *is* the source: the chooser's answer is a path, not a socket.
|
||||
c.cfg->open_waveform = path;
|
||||
report_pick(c);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
|
||||
void on_wave_open_done(GObject* source, GAsyncResult* result, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
GFile* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, nullptr);
|
||||
if (file == nullptr) return; // cancelled
|
||||
char* path = g_file_get_path(file);
|
||||
g_object_unref(file);
|
||||
if (path != nullptr) {
|
||||
c.cfg->open_waveform = path;
|
||||
g_free(path);
|
||||
report_pick(c);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
}
|
||||
|
||||
void on_wave_open_clicked(GtkButton*, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
GtkFileDialog* dialog = gtk_file_dialog_new();
|
||||
gtk_file_dialog_set_title(dialog, "Open an exported waveform");
|
||||
GtkFileFilter* filter = gtk_file_filter_new();
|
||||
gtk_file_filter_set_name(filter, "NumPy export (*.npy)");
|
||||
gtk_file_filter_add_pattern(filter, "*.npy");
|
||||
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
|
||||
g_list_store_append(filters, filter);
|
||||
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
|
||||
gtk_file_dialog_open(dialog, GTK_WINDOW(c.window), nullptr, on_wave_open_done, &c);
|
||||
g_object_unref(filters);
|
||||
g_object_unref(filter);
|
||||
g_object_unref(dialog);
|
||||
}
|
||||
|
||||
void on_edit_clicked(GtkButton* button, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||
open_form(c, idx);
|
||||
}
|
||||
|
||||
void on_forget_clicked(GtkButton* button, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||
@@ -145,13 +325,29 @@ void chooser_populate(Chooser& c) {
|
||||
// The *server*, even for a multicast session: that is the host the file is named after and
|
||||
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
||||
// that is not its identity.
|
||||
const std::string title = s.host + ":" + std::to_string(s.port);
|
||||
const std::string title = s.host + ":" + std::to_string(s.port) + " \u00b7 " + s.config;
|
||||
GtkWidget* title_label = gtk_label_new(title.c_str());
|
||||
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(text), title_label);
|
||||
|
||||
// The note first when there is one: it is what tells two configurations of one source apart,
|
||||
// and a list that shows only counts makes the user open them to find out.
|
||||
if (!s.comment.empty()) {
|
||||
GtkWidget* note = gtk_label_new(s.comment.c_str());
|
||||
gtk_widget_set_halign(note, GTK_ALIGN_START);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(text), note);
|
||||
}
|
||||
|
||||
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
||||
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
||||
if (s.buffer_mb > 0.0) {
|
||||
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
|
||||
}
|
||||
if (s.relative_time) sub += ", relative time";
|
||||
if (s.capture_packets > 0) {
|
||||
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
|
||||
}
|
||||
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
||||
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
||||
gtk_widget_add_css_class(sub_label, "dim-label");
|
||||
@@ -159,8 +355,18 @@ void chooser_populate(Chooser& c) {
|
||||
gtk_box_append(GTK_BOX(text), sub_label);
|
||||
gtk_box_append(GTK_BOX(line), text);
|
||||
|
||||
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
|
||||
gtk_widget_set_tooltip_text(edit,
|
||||
"Edit this configuration: its name and note, the buffer size and "
|
||||
"the clock, or the address it points at");
|
||||
gtk_widget_add_css_class(edit, "flat");
|
||||
gtk_widget_set_valign(edit, GTK_ALIGN_CENTER);
|
||||
g_object_set_data(G_OBJECT(edit), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||
g_signal_connect(edit, "clicked", G_CALLBACK(on_edit_clicked), &c);
|
||||
gtk_box_append(GTK_BOX(line), edit);
|
||||
|
||||
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||||
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
||||
gtk_widget_set_tooltip_text(forget, "Forget this configuration (deletes its saved session)");
|
||||
gtk_widget_add_css_class(forget, "flat");
|
||||
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||||
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||
@@ -173,10 +379,9 @@ void chooser_populate(Chooser& c) {
|
||||
}
|
||||
|
||||
void form_close(Chooser& c) {
|
||||
if (c.form == nullptr) return;
|
||||
GtkWidget* w = c.form;
|
||||
c.form = nullptr; // before the close, so the handler cannot re-enter
|
||||
gtk_window_close(GTK_WINDOW(w));
|
||||
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
|
||||
// an `AdwDialog` closes.
|
||||
if (c.form != nullptr) adw_dialog_close(c.form);
|
||||
}
|
||||
|
||||
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||
@@ -185,97 +390,178 @@ void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
||||
// already does, and the starvation notice covers a source that takes the socket and says
|
||||
// nothing -- so checking it twice would be two expressions of one rule.
|
||||
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
||||
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
||||
const char* text = gtk_editable_get_text(entry);
|
||||
gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
|
||||
}
|
||||
|
||||
void on_form_connect(GtkButton*, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||||
if (server == nullptr || server[0] == '\0') return;
|
||||
c.cfg->server = server;
|
||||
c.cfg->port = static_cast<std::uint16_t>(
|
||||
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
||||
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||
const std::string host = server;
|
||||
const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
|
||||
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
|
||||
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
|
||||
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
|
||||
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
|
||||
if (name.empty()) name = "default";
|
||||
|
||||
c.cfg->server = host;
|
||||
c.cfg->port = port;
|
||||
c.cfg->multicast = group;
|
||||
c.cfg->interface_addr = iface;
|
||||
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
|
||||
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
|
||||
c.cfg->buffer_mb = buffer_mb;
|
||||
c.cfg->relative_time = relative;
|
||||
|
||||
LayoutState st;
|
||||
if (!c.edit_config.empty()) {
|
||||
// An edit keeps everything the configuration already holds -- plots, calibration, math,
|
||||
// measurements -- and changes only what the form shows.
|
||||
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
|
||||
}
|
||||
st.comment = note;
|
||||
st.buffer_mb = buffer_mb;
|
||||
st.relative_time = relative;
|
||||
st.multicast = group;
|
||||
st.interface_addr = iface;
|
||||
|
||||
const bool editing = !c.edit_config.empty();
|
||||
const bool moved =
|
||||
editing && (c.edit_host != host || c.edit_port != port || c.edit_config != name);
|
||||
if (save_layout(configuration_path(host, port, name), st)) {
|
||||
set_active_configuration(host, port, name);
|
||||
// A renamed configuration, or one re-addressed, is a *move*: the old file has no reason to exist
|
||||
// and leaving it would list the same session twice.
|
||||
if (moved) forget_configuration(c.edit_host, c.edit_port, c.edit_config);
|
||||
c.saved_config = name;
|
||||
c.saved_note = note;
|
||||
std::fprintf(stderr,
|
||||
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n",
|
||||
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
|
||||
buffer_mb, relative ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
} else {
|
||||
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
|
||||
name.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
form_close(c);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
|
||||
/// A labelled row for the form's grid.
|
||||
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
||||
GtkWidget* l = gtk_label_new(label);
|
||||
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
||||
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
||||
gtk_widget_set_hexpand(widget, TRUE);
|
||||
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
||||
return widget;
|
||||
}
|
||||
|
||||
void open_form(Chooser& c) {
|
||||
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
|
||||
/// form for both, because the fields are the same and a configuration *is* a host plus its
|
||||
/// settings -- the only difference is where the values start and what the button is called.
|
||||
void open_form(Chooser& c, int edit_index = -1) {
|
||||
if (c.form != nullptr) {
|
||||
gtk_window_present(GTK_WINDOW(c.form));
|
||||
return;
|
||||
}
|
||||
c.form = gtk_window_new();
|
||||
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
||||
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
||||
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
||||
const KnownSource* src =
|
||||
edit_index >= 0 && static_cast<std::size_t>(edit_index) < c.known.size()
|
||||
? &c.known[static_cast<std::size_t>(edit_index)]
|
||||
: nullptr;
|
||||
c.edit_host = src != nullptr ? src->host : std::string();
|
||||
c.edit_port = src != nullptr ? src->port : 0;
|
||||
c.edit_config = src != nullptr ? src->config : std::string();
|
||||
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
||||
gtk_widget_set_margin_start(box, 12);
|
||||
gtk_widget_set_margin_end(box, 12);
|
||||
gtk_widget_set_margin_top(box, 12);
|
||||
gtk_widget_set_margin_bottom(box, 12);
|
||||
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
||||
// A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
|
||||
// `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
|
||||
g_clear_object(&c.form_ui);
|
||||
c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui");
|
||||
if (c.form_ui == nullptr) return;
|
||||
GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form");
|
||||
c.form_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row"));
|
||||
c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row"));
|
||||
c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row"));
|
||||
c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row"));
|
||||
c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row"));
|
||||
c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row"));
|
||||
c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row"));
|
||||
c.form_relative = ADW_SWITCH_ROW(gtk_builder_get_object(c.form_ui, "form_relative_row"));
|
||||
c.form_title = ADW_WINDOW_TITLE(gtk_builder_get_object(c.form_ui, "form_title"));
|
||||
c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply"));
|
||||
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would
|
||||
// be a null row.
|
||||
if (dialog == nullptr || c.form_server == nullptr || c.form_port == nullptr ||
|
||||
c.form_group == nullptr || c.form_iface == nullptr || c.form_name == nullptr ||
|
||||
c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr ||
|
||||
c.form_connect == nullptr) {
|
||||
g_clear_object(&c.form_ui);
|
||||
return;
|
||||
}
|
||||
c.form = ADW_DIALOG(dialog);
|
||||
|
||||
GtkWidget* grid = gtk_grid_new();
|
||||
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
||||
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
||||
gtk_box_append(GTK_BOX(box), grid);
|
||||
// Which mode the form is in, said in the header bar: the title names it and the action's label says
|
||||
// what pressing it does.
|
||||
const bool editing = src != nullptr;
|
||||
if (c.form_title != nullptr) {
|
||||
adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
|
||||
}
|
||||
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
|
||||
|
||||
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
||||
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
||||
// other numeric field in this project -- see the math dialog's fields.
|
||||
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
||||
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
||||
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
||||
// The values: the configuration being edited, or what the command line asked for. The hints live in
|
||||
// the rows' subtitles, so an empty field still says what empty means.
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
|
||||
adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000);
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : "");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
|
||||
src != nullptr ? src->interface_addr.c_str() : "");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
|
||||
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
|
||||
? src->buffer_mb
|
||||
: std::max(1.0, c.cfg->buffer_mb));
|
||||
adw_switch_row_set_active(c.form_relative,
|
||||
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
|
||||
: FALSE);
|
||||
|
||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
form_close(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
c.form_connect = gtk_button_new_with_label("Connect");
|
||||
if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
|
||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
form_close(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
}
|
||||
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
||||
gtk_box_append(GTK_BOX(actions), cancel);
|
||||
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
||||
gtk_box_append(GTK_BOX(box), actions);
|
||||
|
||||
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
||||
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
||||
// not, which is how the math dialog once kept presenting a finalized window.
|
||||
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
||||
// `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
|
||||
// when the dialog really went away -- which is what the comment here used to be about.
|
||||
g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) {
|
||||
static_cast<Chooser*>(user)->form = nullptr;
|
||||
return FALSE;
|
||||
}),
|
||||
&c);
|
||||
|
||||
gtk_window_present(GTK_WINDOW(c.form));
|
||||
gtk_widget_grab_focus(c.form_server);
|
||||
adw_dialog_present(c.form, GTK_WIDGET(c.window));
|
||||
gtk_widget_grab_focus(GTK_WIDGET(c.form_server));
|
||||
}
|
||||
|
||||
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
||||
/// the same four lines in each branch, since both probes assert against this exact line.
|
||||
void report_pick(const Chooser& c) {
|
||||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
|
||||
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
|
||||
if (!c.cfg->open_waveform.empty()) {
|
||||
std::fprintf(stderr, "[sources] picked waveform %s\n", c.cfg->open_waveform.c_str());
|
||||
std::fflush(stderr);
|
||||
return;
|
||||
}
|
||||
// The settings the configuration carried, not just the address: a pick that dropped the buffer
|
||||
// size would look identical here otherwise, and the buffer is the one of them a user notices
|
||||
// only after a long capture has been sized wrong.
|
||||
std::fprintf(stderr,
|
||||
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d "
|
||||
"capture=%u config=%s note='%s'\n",
|
||||
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
|
||||
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0,
|
||||
c.cfg->capture_packets,
|
||||
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
|
||||
: c.saved_config)
|
||||
.c_str(),
|
||||
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().comment)
|
||||
: c.saved_note)
|
||||
.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
@@ -293,10 +579,55 @@ gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifier
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/// The chooser's way into the preferences -- and the reason the form has no owner: this dialog runs
|
||||
/// before `App` exists. Relative time has to reach the run this chooser is about to start, because
|
||||
/// `main()` plans the buffers only after this returns; the rest is read from the file when that run
|
||||
/// starts, so it is honoured without anything here doing it.
|
||||
void open_chooser_preferences(Chooser& c) {
|
||||
ui_impl::prefs_open(c.prefs, c.window, load_settings(), [&c](const Settings& s, bool written) {
|
||||
// The newest statement of intent there is for this run: it beats the list's own copy and the
|
||||
// stored configuration, and it works in both directions -- turning it on *and* off.
|
||||
c.cfg->relative_time = s.relative_time;
|
||||
c.cfg->relative_time_specified = true;
|
||||
if (!written) {
|
||||
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||||
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
|
||||
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
|
||||
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
|
||||
/// are read by the probes that drive them.
|
||||
void report_chooser_ui(const Chooser& c) {
|
||||
std::fprintf(stderr, "[sources] chooser ui:");
|
||||
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
|
||||
w = gtk_widget_get_next_sibling(w)) {
|
||||
// A page is not the child: `AdwViewStackPage` is a `GObject` that *wraps* one, so the stack is what
|
||||
// turns a child back into its page.
|
||||
AdwViewStackPage* page = adw_view_stack_get_page(ADW_VIEW_STACK(c.page_stack), w);
|
||||
if (page == nullptr) continue;
|
||||
std::fprintf(stderr, " page='%s' title='%s' icon=%s", adw_view_stack_page_get_name(page),
|
||||
adw_view_stack_page_get_title(page),
|
||||
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
|
||||
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
|
||||
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
|
||||
ADW_VIEW_STACK(c.page_stack)
|
||||
? 1
|
||||
: 0,
|
||||
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
|
||||
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
gboolean pick_probe(gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
||||
@@ -304,7 +635,49 @@ gboolean pick_probe(gpointer user) {
|
||||
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
||||
c.pick_source = 0;
|
||||
|
||||
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
|
||||
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
|
||||
report_chooser_ui(c);
|
||||
|
||||
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
|
||||
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
|
||||
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
|
||||
// which is the half of the contract the app's probe cannot see. The window is deliberately left
|
||||
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
|
||||
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
|
||||
open_chooser_preferences(c);
|
||||
std::fprintf(stderr, "[sources] prefs: opened=%d rows=%d\n", c.prefs.dialog != nullptr ? 1 : 0,
|
||||
c.prefs.dir_row != nullptr ? 1 : 0);
|
||||
ui_impl::prefs_probe_change(c.prefs);
|
||||
const Settings back = load_settings();
|
||||
std::fprintf(stderr,
|
||||
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
|
||||
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
|
||||
back.theme.c_str(), c.cfg->relative_time ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
|
||||
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
|
||||
// answer without the dialog -- the same field, set the same way.
|
||||
if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) {
|
||||
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(c.page_stack), "waveforms");
|
||||
std::fprintf(stderr, "[sources] waveforms: %zu recent", c.waves.size());
|
||||
for (const std::string& p : c.waves) {
|
||||
std::fprintf(stderr, " %s(%s)", p.c_str(), ::access(p.c_str(), R_OK) == 0 ? "ok" : "missing");
|
||||
}
|
||||
std::fprintf(stderr, " page=%s\n",
|
||||
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
if (const char* path = g_getenv("UDPSCOPE_PROBE_WAVE")) {
|
||||
c.cfg->open_waveform = path;
|
||||
report_pick(c);
|
||||
chooser_finish(c, true);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row: the address, and the four
|
||||
// things a host could not be given before -- a name, a note, a buffer size and a clock.
|
||||
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
||||
open_form(c);
|
||||
const std::string s = spec;
|
||||
@@ -312,15 +685,72 @@ gboolean pick_probe(gpointer user) {
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
||||
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
||||
if (colon != std::string::npos) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
||||
adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1));
|
||||
}
|
||||
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
|
||||
adw_spin_row_set_value(c.form_buffer, 32);
|
||||
adw_switch_row_set_active(c.form_relative, TRUE);
|
||||
// 8/8: the form's row titles are mnemonics (`_Server`, `_Port`, `Multicast _group`, ...). The two
|
||||
// buttons already asked for it; the rows did not, and drew their underscores.
|
||||
std::fprintf(stderr, "[sources] form mnemonics=%d/8\n",
|
||||
(row_underlines(G_OBJECT(c.form_server)) + row_underlines(G_OBJECT(c.form_port)) +
|
||||
row_underlines(G_OBJECT(c.form_group)) + row_underlines(G_OBJECT(c.form_iface)) +
|
||||
row_underlines(G_OBJECT(c.form_name)) + row_underlines(G_OBJECT(c.form_note)) +
|
||||
row_underlines(G_OBJECT(c.form_buffer)) + row_underlines(G_OBJECT(c.form_relative))));
|
||||
std::fprintf(stderr,
|
||||
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
|
||||
"connect_sensitive=%d\n",
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||
adw_spin_row_get_value(c.form_buffer),
|
||||
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||||
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||
report_pick(c);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// `UDPSCOPE_PROBE_EDITHOST=host:port` opens the existing configuration's form, changes its
|
||||
// settings and saves: the path a user reaches by clicking the row's pencil.
|
||||
if (const char* spec = g_getenv("UDPSCOPE_PROBE_EDITHOST")) {
|
||||
const std::string want = spec;
|
||||
int idx = -1;
|
||||
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
||||
if (c.known[i].host + ":" + std::to_string(c.known[i].port) == want) {
|
||||
idx = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx < 0) {
|
||||
std::fprintf(stderr, "[sources] edit: no configuration for '%s'\n", want.c_str());
|
||||
std::fflush(stderr);
|
||||
chooser_finish(c, false);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
open_form(c, idx);
|
||||
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
|
||||
// against, and the button's label says which mode it is in.
|
||||
std::fprintf(stderr,
|
||||
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||
adw_spin_row_get_value(c.form_buffer),
|
||||
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
|
||||
adw_spin_row_set_value(c.form_buffer, 16);
|
||||
adw_switch_row_set_active(c.form_relative, FALSE);
|
||||
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
|
||||
// survive, or the chooser lists the same session twice.
|
||||
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name), rename_to);
|
||||
}
|
||||
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||
report_pick(c);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
||||
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
||||
// even when the only list child is the empty-state placeholder label.
|
||||
@@ -341,6 +771,28 @@ gboolean pick_probe(gpointer user) {
|
||||
|
||||
} // namespace
|
||||
|
||||
void apply_stored_settings(Config& cfg) {
|
||||
const std::string want = active_configuration(cfg.server, cfg.port);
|
||||
if (want.empty()) return;
|
||||
LayoutState st;
|
||||
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
|
||||
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
|
||||
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
|
||||
// surprise from whoever just made it.
|
||||
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
|
||||
const bool relative = !cfg.relative_time_specified && st.relative_time;
|
||||
if (relative) cfg.relative_time = true;
|
||||
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
|
||||
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
|
||||
st.capture_packets > 0;
|
||||
if (applied) {
|
||||
std::fprintf(stderr,
|
||||
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
|
||||
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
bool choose_source(Config& cfg) {
|
||||
const std::vector<KnownSource> known = known_sources();
|
||||
|
||||
@@ -359,45 +811,53 @@ bool choose_source(Config& cfg) {
|
||||
|
||||
Chooser c;
|
||||
c.cfg = &cfg;
|
||||
c.window = gtk_window_new();
|
||||
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
||||
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
||||
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
||||
// The frame is a `.ui` file; the rows are not, because a source row and a waveform row are data and are
|
||||
// rebuilt whenever the known hosts or the recent files change. `make test-ui` checks the file's types
|
||||
// against the GIR; this checks that the code and the file still agree on the names, which nothing else
|
||||
// can -- a missing id would otherwise open a window with a hole in it, or crash on the first read.
|
||||
c.ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-chooser.ui");
|
||||
c.window = GTK_WIDGET(gtk_builder_get_object(c.ui, "chooser_window"));
|
||||
c.page_stack = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_stack"));
|
||||
c.switcher = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_switcher"));
|
||||
c.list = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_list"));
|
||||
c.wave_list = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_list"));
|
||||
c.prefs_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "prefs_button"));
|
||||
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
|
||||
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
|
||||
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
|
||||
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
|
||||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
|
||||
c.waves_open == nullptr || c.cancel_button == nullptr) {
|
||||
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
|
||||
std::fflush(stderr);
|
||||
return false;
|
||||
}
|
||||
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
|
||||
|
||||
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||
gtk_widget_set_vexpand(scroller, TRUE);
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
c.list = gtk_list_box_new();
|
||||
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
||||
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
||||
gtk_box_append(GTK_BOX(box), scroller);
|
||||
|
||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_widget_set_margin_start(actions, 12);
|
||||
gtk_widget_set_margin_end(actions, 12);
|
||||
gtk_widget_set_margin_top(actions, 12);
|
||||
gtk_widget_set_margin_bottom(actions, 12);
|
||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
||||
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
|
||||
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
|
||||
g_signal_connect(c.new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_form(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
g_signal_connect(c.cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||
}),
|
||||
&c);
|
||||
gtk_box_append(GTK_BOX(actions), new_host);
|
||||
gtk_box_append(GTK_BOX(actions), cancel);
|
||||
gtk_box_append(GTK_BOX(box), actions);
|
||||
// The preferences are reachable from here as well as from the main window's menu: this dialog is the
|
||||
// only window that exists before anything is planned, so it is where a user still has the chance to say
|
||||
// how this run should behave.
|
||||
g_signal_connect(c.prefs_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_chooser_preferences(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
|
||||
chooser_populate(c);
|
||||
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
||||
// The other page's rows too: fills the sources, and this is the pair of it.
|
||||
chooser_populate_waves(c);
|
||||
if (c.known.empty()) gtk_widget_grab_focus(c.new_host);
|
||||
|
||||
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
||||
GtkEventController* keys = gtk_event_controller_key_new();
|
||||
|
||||
@@ -16,4 +16,14 @@ namespace udpscope {
|
||||
/// same process, which works because `gtk_init()` is idempotent.
|
||||
bool choose_source(Config& cfg);
|
||||
|
||||
/// Fills in the settings a *stored* configuration carries for `cfg`'s source -- the buffer size,
|
||||
/// relative time and the packet capture's ring size -- leaving alone anything the command line
|
||||
/// actually asked for.
|
||||
///
|
||||
/// The chooser applies these when it opens a source; a source named with `--server` never reaches
|
||||
/// the chooser, so without this a saved host's buffer size and clock were silently ignored and the
|
||||
/// edit that set them read as one that never took effect. The layout is *not* applied here: it
|
||||
/// names signals, so it is loaded by the app once a CONFIG has described them.
|
||||
void apply_stored_settings(Config& cfg);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -79,6 +79,21 @@ std::string format_detail(const StatusSnapshot& s) {
|
||||
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||
static_cast<unsigned long long>(s.time_stride));
|
||||
std::string out = buf;
|
||||
// Nothing about resequencing is said at all while all six read zero, which is a stream that
|
||||
// never lost ordering: the field would otherwise be six zeros on every clean run, and this
|
||||
// string is read by eye out of the exit dump.
|
||||
if (s.resequenced || s.late || s.stale || s.holes || s.hole_slots || s.backfilled) {
|
||||
char note[160];
|
||||
std::snprintf(note, sizeof(note),
|
||||
" | reseq %llu late %llu stale %llu holes %llu (%llu slots, %llu filled)",
|
||||
static_cast<unsigned long long>(s.resequenced),
|
||||
static_cast<unsigned long long>(s.late),
|
||||
static_cast<unsigned long long>(s.stale),
|
||||
static_cast<unsigned long long>(s.holes),
|
||||
static_cast<unsigned long long>(s.hole_slots),
|
||||
static_cast<unsigned long long>(s.backfilled));
|
||||
out += note;
|
||||
}
|
||||
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||
if (s.trigger_on) {
|
||||
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||
|
||||
@@ -56,6 +56,16 @@ struct StatusSnapshot {
|
||||
/// the plot.
|
||||
bool paused = false;
|
||||
double paused_for_s = 0.0;
|
||||
|
||||
/// The resequencing counters, gathered with `packets`/`frames`/`gaps` so the detail tier and
|
||||
/// the dump quote one consistent set. All six zero means the stream never lost ordering, and
|
||||
/// the detail tier then says nothing about it at all.
|
||||
std::uint64_t resequenced = 0;
|
||||
std::uint64_t late = 0;
|
||||
std::uint64_t stale = 0;
|
||||
std::uint64_t holes = 0;
|
||||
std::uint64_t hole_slots = 0;
|
||||
std::uint64_t backfilled = 0;
|
||||
};
|
||||
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||
|
||||
+32
-36
@@ -4,6 +4,7 @@
|
||||
#include "trigger.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
@@ -12,15 +13,18 @@ double TriggerConfig::post_span_s(double buffer_span_s) const {
|
||||
return buffer_span_s * (1.0 - pre);
|
||||
}
|
||||
|
||||
void TriggerEngine::configure(const TriggerConfig& cfg, double post_seconds,
|
||||
double pre_seconds, double oldest_s) {
|
||||
const bool channel_changed = cfg.channel != cfg_.channel;
|
||||
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||
double post_seconds, double pre_seconds, double oldest_s) {
|
||||
const bool channel_changed = channel_name != channel_name_;
|
||||
if (channel_changed) channel_name_ = channel_name;
|
||||
cfg_ = cfg;
|
||||
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
|
||||
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
|
||||
oldest_s_ = oldest_s;
|
||||
// A no-op unless the condition actually changed: this runs on every packet.
|
||||
detector_.configure(cfg_.condition);
|
||||
if (channel_changed) {
|
||||
have_prev_ = false;
|
||||
detector_.reset();
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0;
|
||||
}
|
||||
@@ -31,23 +35,21 @@ void TriggerEngine::arm(double now_s) noexcept {
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0.0;
|
||||
holdoff_until_s_ = now_s;
|
||||
have_prev_ = false;
|
||||
detector_.reset();
|
||||
}
|
||||
|
||||
bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
||||
|
||||
// Hold-off: settled, waiting for the next edge.
|
||||
if (!pending_ && now_s < holdoff_until_s_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
last_t_s_ = now_s;
|
||||
return false;
|
||||
}
|
||||
// The detector is fed **first and unconditionally**, on every sample the engine sees --
|
||||
// including during the hold-off and during an open capture, where its answer is thrown
|
||||
// away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch
|
||||
// can never be closed, and the symptom would be a width condition that silently stops
|
||||
// firing after the first hold-off.
|
||||
const double event_t = detector_.feed(value, now_s);
|
||||
|
||||
if (!have_prev_) {
|
||||
prev_ = value;
|
||||
have_prev_ = true;
|
||||
// Hold-off: settled, waiting for the next event.
|
||||
if (!pending_ && now_s < holdoff_until_s_) {
|
||||
last_t_s_ = now_s;
|
||||
return false;
|
||||
}
|
||||
@@ -59,7 +61,6 @@ bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
|
||||
// so the capture covers exactly the requested span whatever the cadence is.
|
||||
++post_accum_count_;
|
||||
prev_ = value;
|
||||
if (now_s - pending_start_s_ >= post_seconds_) {
|
||||
pending_ = false;
|
||||
post_accum_count_ = 0;
|
||||
@@ -73,33 +74,28 @@ bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Edge detection against the level.
|
||||
const bool crossed_up = prev_ < cfg_.level && value >= cfg_.level;
|
||||
const bool crossed_down = prev_ > cfg_.level && value <= cfg_.level;
|
||||
const bool fired = (cfg_.edge == TriggerEdge::Rising && crossed_up) ||
|
||||
(cfg_.edge == TriggerEdge::Falling && crossed_down) ||
|
||||
(cfg_.edge == TriggerEdge::Both && (crossed_up || crossed_down));
|
||||
prev_ = value;
|
||||
if (!fired) return false;
|
||||
if (!std::isfinite(event_t)) return false;
|
||||
|
||||
// The pre-trigger window has to already be behind this edge, or the capture opens
|
||||
// on a half-filled pre window and the event is short on its left side -- the plot
|
||||
// then shows a window that is correctly sized but only partly drawn. The buffer
|
||||
// fills whether or not the trigger is armed, so this normally passes on the first
|
||||
// edge; it only bites just after a rebuild (a CONFIG, or enabling the signal),
|
||||
// when the history genuinely is not there yet.
|
||||
if (now_s - oldest_s_ < pre_seconds_) return false;
|
||||
// The pre-trigger window has to already be behind the event, or the capture opens on a
|
||||
// half-filled pre window and the event is short on its left side -- the plot then shows a
|
||||
// window that is correctly sized but only partly drawn. The buffer fills whether or not the
|
||||
// trigger is armed, so this normally passes on the first event; it only bites just after a
|
||||
// rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet.
|
||||
//
|
||||
// Measured from the *event's own* time, not from `now_s`: for a qualified condition the two
|
||||
// differ (a pulse is reported at its closing edge), and the pre window belongs to the event.
|
||||
if (event_t - oldest_s_ < pre_seconds_) return false;
|
||||
|
||||
// The edge's own time, which is what a trigger marker marks: the latch itself
|
||||
// may happen a post-window later.
|
||||
last_trigger_s_ = now_s;
|
||||
// The event's own time, which is what a trigger marker marks: the latch itself may happen
|
||||
// a post-window later.
|
||||
last_trigger_s_ = event_t;
|
||||
|
||||
// The edge always opens a capture; there is no "latch on the edge itself" case.
|
||||
// The event always opens a capture; there is no "latch on the event itself" case.
|
||||
// A zero-length post window latches on the very next sample, which is the least a
|
||||
// capture can hold, and every longer window is just more samples -- no conversion
|
||||
// from seconds to a sample count, so no conversion to get wrong.
|
||||
pending_ = true;
|
||||
pending_start_s_ = now_s;
|
||||
pending_start_s_ = event_t;
|
||||
post_accum_count_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
+25
-12
@@ -4,12 +4,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "condition.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Which way the trigger signal crosses the level.
|
||||
enum class TriggerEdge { Rising, Falling, Both };
|
||||
|
||||
/// How an armed trigger re-arms after it fired.
|
||||
enum class TriggerMode { Normal, Single };
|
||||
|
||||
@@ -23,8 +23,10 @@ struct TriggerConfig {
|
||||
bool enabled = false; ///< trigger mode on/off (toolbar).
|
||||
bool armed = false; ///< arm/disarm button.
|
||||
int channel = -1; ///< index into ScopeModel::channels(), -1 = none.
|
||||
double level = 0.0;
|
||||
TriggerEdge edge = TriggerEdge::Rising;
|
||||
/// What counts as an event on that channel: the level and edge, and for the qualified
|
||||
/// shapes the width, band or timeout as well. The engine does not interpret it -- it
|
||||
/// hands it to an `EventDetector`.
|
||||
EventCondition condition{};
|
||||
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
||||
double holdoff_s = 0.010; ///< default from the spec.
|
||||
TriggerMode mode = TriggerMode::Normal;
|
||||
@@ -39,7 +41,16 @@ struct TriggerConfig {
|
||||
class TriggerEngine {
|
||||
public:
|
||||
/// Configures the channel and the post-trigger window. Resets the state when the
|
||||
/// channel changes.
|
||||
/// trigger moves to a different channel.
|
||||
///
|
||||
/// `channel_name` is that channel's name, and it -- not `cfg.channel` -- is what
|
||||
/// identifies it here. The index is not stable: every rebuild of the channel list
|
||||
/// renumbers it (enabling a signal that sorts before the trigger's own is enough),
|
||||
/// and the model re-resolves the trigger by name afterwards. Keying the reset on the
|
||||
/// index therefore made any such rebuild abandon an open capture *and set no
|
||||
/// hold-off*, so the next edge inside the post window became t0 -- which is the
|
||||
/// "re-triggers mid-capture and ignores the hold-off" defect. Choosing a genuinely
|
||||
/// different source still resets, because the name changes with it.
|
||||
///
|
||||
/// `post_seconds` is the post-trigger window in **stream seconds**, the same clock
|
||||
/// `process()` is fed and the same clock the x axis reads, so the capture covers
|
||||
@@ -57,8 +68,8 @@ class TriggerEngine {
|
||||
/// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real
|
||||
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
|
||||
/// off, so the history is usually already there and waiting would be wrong.
|
||||
void configure(const TriggerConfig& cfg, double post_seconds, double pre_seconds,
|
||||
double oldest_s);
|
||||
void configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||||
double post_seconds, double pre_seconds, double oldest_s);
|
||||
/// Disarms without forgetting the configuration.
|
||||
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||
void arm(double now_s) noexcept;
|
||||
@@ -82,8 +93,8 @@ class TriggerEngine {
|
||||
/// pre-trigger window an edge needs before it may open a capture.
|
||||
double history() const noexcept { return last_t_s_ - oldest_s_; }
|
||||
double pre_seconds() const noexcept { return pre_seconds_; }
|
||||
double last_value() const noexcept { return prev_; }
|
||||
bool have_last() const noexcept { return have_prev_; }
|
||||
double last_value() const noexcept { return detector_.last_value(); }
|
||||
bool have_last() const noexcept { return detector_.have_last(); }
|
||||
double last_t() const noexcept { return last_t_s_; }
|
||||
double holdoff_until() const noexcept { return holdoff_until_s_; }
|
||||
|
||||
@@ -95,9 +106,11 @@ class TriggerEngine {
|
||||
|
||||
private:
|
||||
TriggerConfig cfg_{};
|
||||
std::string channel_name_; ///< the trigger channel's stable identity (see `configure`).
|
||||
bool armed_ = false;
|
||||
bool have_prev_ = false;
|
||||
double prev_ = 0.0;
|
||||
/// What an event *is*. The engine owns when one may open a capture; this owns whether
|
||||
/// the sample is one at all.
|
||||
EventDetector detector_{};
|
||||
bool pending_ = false; ///< an edge was seen; collecting the post window
|
||||
double pending_start_s_ = 0.0;
|
||||
double holdoff_until_s_ = 0.0;
|
||||
|
||||
+2010
-4601
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The cursors panel: a header with the mode's dropdown and the reset button, and the grid the two
|
||||
cursor times are read in, one line per row of the figure.
|
||||
|
||||
The grid's *contents* are not here: they follow the cursor mode and the rows, and they are rebuilt
|
||||
whenever either changes (`rebuild_cursors_pane()`). What is here is the panel's shape, the dropdown
|
||||
and the button, and the grid they sit above.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkBox" id="cursors_panel">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title">
|
||||
<property name="label" translatable="yes">Cursors</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkDropDown" id="mode_drop"/>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="reset">
|
||||
<property name="icon-name">edit-clear-symbolic</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="css-classes">flat</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkGrid" id="body">
|
||||
<property name="row-spacing">2</property>
|
||||
<property name="column-spacing">4</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The events pane: the revealer that slides it in, the header with the way back to the live stream, the two
|
||||
actions that read the selection, and the list inside its scroller.
|
||||
|
||||
The rows are not here. There is one per latched event in the cache, it carries the time, the duration, the
|
||||
signals and the rate, and the whole list is rebuilt whenever the cache changes — so the file holds the
|
||||
frame and the code holds the rows.
|
||||
|
||||
The list is multiple-selection and activates on *double* click, which is a statement about what the pane is
|
||||
for rather than a default: its other job is choosing which events to compare or accumulate, and with
|
||||
single-click activation a plain click — the gesture that builds a selection — would replay the event
|
||||
instead. Both actions start insensitive because a selection starts empty, and `update_group_buttons()`
|
||||
owns them from there.
|
||||
|
||||
A width floor, not a width: the divider sets the pane's width, and this is what it needs to stay usable at
|
||||
its narrowest.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkRevealer" id="events_pane">
|
||||
<property name="transition-type">slide-right</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="events_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="width-request">170</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<property name="margin-top">6</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="events_head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="events_title">
|
||||
<property name="label" translatable="yes"><b>Events</b></property>
|
||||
<property name="use-markup">1</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="events_live">
|
||||
<property name="label" translatable="yes">Live</property>
|
||||
<property name="css-classes">flat</property>
|
||||
<property name="tooltip-text" translatable="yes">go back to the live stream after replaying a stored event</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="events_actions">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="events_compare">
|
||||
<property name="label" translatable="yes">Compare</property>
|
||||
<property name="tooltip-text" translatable="yes">the two selected events in two plots side by side, with the axes synchronised</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="sensitive">0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="events_accumulate">
|
||||
<property name="label" translatable="yes">Accumulate</property>
|
||||
<property name="tooltip-text" translatable="yes">up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="sensitive">0</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="events_scroll">
|
||||
<property name="vexpand">1</property>
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="events_list">
|
||||
<property name="activate-on-single-click">0</property>
|
||||
<property name="selection-mode">multiple</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The header bar: the time-window control's slot, the trigger, pause and events group with the separator that
|
||||
marks where the *view's* controls end, and the five at the far end.
|
||||
|
||||
The bar carries only the controls you reach for *while watching a signal*; everything you set up once lives
|
||||
in the overflow menu, which is code — its rows act on the `RowSet` directly and two of them are toggles
|
||||
whose state the rest of the interface reads, so a `GMenu` model would only add a second copy of that state.
|
||||
|
||||
The order is the file's, and it is the one thing here that is not self-evident: `pack_end()` lays its
|
||||
children out right to left, so a `<child type="end">` is added leftwards-from-the-right in document order
|
||||
and the screen reads the reverse of it — cursors, measurements, math, sidebar, menu.
|
||||
|
||||
The first start child is a slot and not the control: the time-window control is code (its menu's rows come
|
||||
from the buffer's limits), and `gtk_header_bar_pack_start()` appends *after* whatever a file has already
|
||||
packed, so a control added by code would land to the right of this whole group instead of at its head.
|
||||
|
||||
The events toggle starts hidden: the event history is a *trigger* thing, and asking for it is one click
|
||||
from the mode that produces it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkHeaderBar" id="header_bar">
|
||||
<child type="start">
|
||||
<object class="GtkBox" id="window_control_slot">
|
||||
<property name="orientation">horizontal</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="start">
|
||||
<object class="GtkToggleButton" id="trigger_toggle">
|
||||
<property name="icon-name">udpscope-trigger-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">trigger mode: freeze the view and show one captured event at a time</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="start">
|
||||
<object class="GtkToggleButton" id="pause_button">
|
||||
<property name="icon-name">media-playback-pause-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">pause acquisition</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="start">
|
||||
<object class="GtkSeparator">
|
||||
<property name="orientation">vertical</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="start">
|
||||
<object class="GtkToggleButton" id="events_toggle">
|
||||
<property name="icon-name">udpscope-events-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">the event history: the triggered acquisitions kept in the cache, with a picture of each</property>
|
||||
<property name="visible">0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="menu_button">
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">view options</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="sidebar_toggle">
|
||||
<property name="icon-name">sidebar-show-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">show the side panel</property>
|
||||
<property name="active">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="math_toggle">
|
||||
<property name="icon-name">udpscope-math-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">channels derived from the others by a graph of operations: gain, absolute value, filters, sums and the rest. An enabled one is acquired, plotted and measured exactly like a signal</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="measures_toggle">
|
||||
<property name="icon-name">udpscope-measure-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">compute a value over the visible window of a signal, and optionally mark it on the plot</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkToggleButton" id="cursors_toggle">
|
||||
<property name="icon-name">udpscope-cursors-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">the cursor instruments: two times, two levels, or the two read off each trace where they cross</property>
|
||||
<property name="active">0</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The help window's chrome: a window, its own header bar, and a two-pane split whose sidebar is the
|
||||
list of pages and whose content is one page at a time.
|
||||
|
||||
The split is AdwNavigationSplitView rather than a GtkBox with a separator in it, so the sidebar
|
||||
collapses on a narrow window and comes back behind the header bar's button instead of squeezing the
|
||||
text. On a wide one it is the same two panes it always was.
|
||||
|
||||
The page list itself is *not* here: its rows are the fourteen pages in `help_window.cpp`, each with
|
||||
section headings between the groups, and a template cannot know them. What is here is the shape they
|
||||
are put into: an id to find the list by, the scroller around the page, and the label that holds one
|
||||
page's markup with the screenshot column above it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwWindow" id="help_window">
|
||||
<property name="title" translatable="yes">udpscope help</property>
|
||||
<property name="default-width">860</property>
|
||||
<property name="default-height">640</property>
|
||||
<property name="content">
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-title">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="AdwNavigationSplitView">
|
||||
<property name="sidebar">
|
||||
<object class="AdwNavigationPage">
|
||||
<property name="title" translatable="yes">Pages</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="vscrollbar-policy">automatic</property>
|
||||
<property name="width-request">220</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="page_list">
|
||||
<property name="selection-mode">single</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="content">
|
||||
<object class="AdwNavigationPage">
|
||||
<property name="title" translatable="yes">Page</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="vscrollbar-policy">automatic</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox" id="page_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="shot_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">8</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">14</property>
|
||||
<property name="visible">0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="body">
|
||||
<property name="use-markup">1</property>
|
||||
<property name="wrap">1</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="yalign">0</property>
|
||||
<property name="selectable">1</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">14</property>
|
||||
<property name="margin-bottom">14</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
|
||||
events pane's divider, the plot column with the status line under it, and the sidebar's scroller.
|
||||
|
||||
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
|
||||
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
|
||||
the reasons it is here:
|
||||
|
||||
- its width is *its own*: `sidebar-width-fraction` in **pixels** (the unit says so), floored by
|
||||
`min-sidebar-width`. A `GtkPaned` kept a child's size in a divider position, which is a number of
|
||||
pixels *of the window* — so on a window narrower than that number the sidebar came up with no
|
||||
width at all, which is the bug this widget replaces;
|
||||
- it resizes by dragging its own edge, within `min-sidebar-width`..`max-sidebar-width`;
|
||||
- when the window is too narrow for both columns it *overlays* the plot instead of squeezing it,
|
||||
which is what `collapsed` is for; `tick()` sets it from the window's width.
|
||||
|
||||
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
|
||||
the window does not depend on the stream, the trigger or the layout — while the subtrees that do are
|
||||
built by code and put in place: the events pane, into `events_divider`'s start side; the two plot
|
||||
areas, into the split inside `plot_box`; the sidebar's panels, appended to `side_column`; and the
|
||||
status popover, attached to `status_info`.
|
||||
|
||||
The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()`
|
||||
because every button on it is wired to an action. The status line is not a bottom bar — it belongs to
|
||||
the plot column, not to the window, so it stays inside `left_column` where the divider can move past
|
||||
it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkApplicationWindow" id="main_window">
|
||||
<property name="title">udpscope</property>
|
||||
<property name="icon-name">org.udpscope.app</property>
|
||||
<!-- The size the window asks for, stated in code as well as here: a window whose content has a
|
||||
larger minimum opens at that minimum instead (see `ui.cpp`). -->
|
||||
<property name="default-width">1280</property>
|
||||
<property name="default-height">800</property>
|
||||
<child>
|
||||
<object class="AdwOverlaySplitView" id="side_view">
|
||||
<property name="sidebar-position">end</property>
|
||||
<!-- Its width in pixels, not as a fraction of the window: 320 on a first run, and never less
|
||||
than 240 however small the window gets. -->
|
||||
<!-- Both ends of the range, in pixels: `sidebar-width-fraction` is a 0..1 *fraction* of the
|
||||
window (setting a pixel count there is a GObject critical and falls back to 0.25), so the
|
||||
width is pinned by min and max instead. -->
|
||||
<property name="sidebar-width-unit">px</property>
|
||||
<property name="min-sidebar-width">320</property>
|
||||
<property name="max-sidebar-width">320</property>
|
||||
<property name="sidebar">
|
||||
<!-- The column scrolls as a whole, so a long signal list or a revealed trigger panel can
|
||||
never push either panel out of the window. -->
|
||||
<object class="GtkScrolledWindow" id="side_scroll">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="vscrollbar-policy">automatic</property>
|
||||
<property name="min-content-width">300</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox" id="side_column">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="width-request">300</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="content">
|
||||
<object class="GtkPaned" id="events_divider">
|
||||
<property name="orientation">horizontal</property>
|
||||
<!-- Shrinkable, or the pane's own minimum is reserved as an empty column even while it is
|
||||
closed and the plot pays for a pane nobody opened. -->
|
||||
<property name="shrink-start-child">1</property>
|
||||
<property name="resize-start-child">0</property>
|
||||
<child type="end">
|
||||
<object class="GtkBox" id="left_column">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="hexpand">1</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="plot_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="homogeneous">1</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="vexpand">1</property>
|
||||
<child>
|
||||
<object class="GtkPaned" id="plot_split">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="shrink-end-child">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="status_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-top">2</property>
|
||||
<property name="margin-bottom">2</property>
|
||||
<property name="margin-start">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="status_badge"/>
|
||||
</child>
|
||||
<child>
|
||||
<!-- The line is rewritten every frame and its counters grow, so it is
|
||||
ellipsized: a label's minimum width is its text, and an unellipsized one
|
||||
would push the divider around as the numbers change. -->
|
||||
<object class="GtkLabel" id="status_label">
|
||||
<property name="label">starting…</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="halign">fill</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkMenuButton" id="status_info">
|
||||
<property name="icon-name">help-about-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Stream and buffer detail</property>
|
||||
<property name="valign">center</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The math channel editor's window and its identity row: the name the channel is called, and the unit its
|
||||
readouts carry.
|
||||
|
||||
The canvas below them is *declared* here but *drawn* by code (`GtkDrawingArea`); the parameter form beside
|
||||
it and the palette are code as well, because the form follows which node is selected and the palette is the
|
||||
operation table itself — neither is data a file should hold. What is here is the frame: the window, the
|
||||
identity row, the body with the canvas in it, and the two buttons that end the dialog.
|
||||
|
||||
The window's title is not here: it says which mode the editor is in ("Edit math channel" against "New math
|
||||
channel"), which the code that opens it knows and a file cannot.
|
||||
|
||||
The name takes the slack and the unit does not: the name is also how another graph refers to this channel
|
||||
as a source, so it is the one that should grow as the window does.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkWindow" id="math_window">
|
||||
<property name="default-width">720</property>
|
||||
<property name="default-height">420</property>
|
||||
<property name="modal">1</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_outer">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">8</property>
|
||||
<property name="margin-top">10</property>
|
||||
<property name="margin-bottom">10</property>
|
||||
<property name="margin-start">10</property>
|
||||
<property name="margin-end">10</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="GtkEntry" id="math_name">
|
||||
<property name="placeholder-text" translatable="yes">name</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkEntry" id="math_unit">
|
||||
<property name="placeholder-text" translatable="yes">unit (optional)</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_body">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">10</property>
|
||||
<child>
|
||||
<object class="GtkDrawingArea" id="math_canvas">
|
||||
<property name="width-request">420</property>
|
||||
<property name="height-request">300</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="vexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_actions">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="halign">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="math_cancel">
|
||||
<property name="label" translatable="yes">Cancel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="math_apply">
|
||||
<property name="label" translatable="yes">Add</property>
|
||||
<property name="css-classes">suggested-action</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The math panel: a heading row with the button that adds a channel, and the list they go in.
|
||||
|
||||
The rows are not here. A math channel's row carries its name, its unit, its expression, its colour and its
|
||||
own edit and remove buttons, all of which follow the channel — so the file holds the frame and the code
|
||||
holds the rows, as in the measurements and signals panels beside it.
|
||||
|
||||
The heading takes the slack instead of the button, which is what keeps the `+` at the row's end however
|
||||
long the title or the panel is.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkBox" id="math_panel">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_head">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="math_title">
|
||||
<property name="label" translatable="yes">Math channels</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="math_add">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="css-classes">flat</property>
|
||||
<property name="tooltip-text" translatable="yes">add a math channel</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="math_list">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">2</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The measurements panel: a title row with the button that adds one, the one statistics switch the whole
|
||||
panel shares, and the box the readings go into.
|
||||
|
||||
The readings are not here. Each one is a measurement the user placed — its name, its markers and its
|
||||
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in
|
||||
(`measures_list`) plus the three widgets whose state outlives them.
|
||||
|
||||
One statistics switch for the panel rather than one per reading: statistics are either what a user is
|
||||
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
|
||||
question asked once.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkBox" id="measures_panel">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-start">6</property>
|
||||
<property name="margin-end">6</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="measures_title">
|
||||
<property name="label" translatable="yes">Measurements</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="measures_add">
|
||||
<property name="icon-name">list-add-symbolic</property>
|
||||
<property name="css-classes">flat</property>
|
||||
<property name="tooltip-text" translatable="yes">add a measurement</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="GtkCheckButton" id="measures_stats_check">
|
||||
<property name="label" translatable="yes">Statistics</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="tooltip-text" translatable="yes">show how each reading has varied: passes, mean, standard deviation and extremes, under the reading itself</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="measures_stats_reset">
|
||||
<property name="label" translatable="yes">Reset</property>
|
||||
<property name="css-classes">flat</property>
|
||||
<property name="sensitive">0</property>
|
||||
<property name="tooltip-text" translatable="yes">start every measurement's statistics again from the next pass</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="measures_list">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">2</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The preferences, as an AdwPreferencesDialog: pages of groups of rows, which is what every GNOME
|
||||
application shows and what libadwaita's own widgets are for. The rows are AdwEntryRow, AdwSpinRow,
|
||||
AdwSwitchRow, AdwComboRow and AdwButtonRow rather than a label and a bare GtkEntry in a GtkBox, so
|
||||
they behave the way a reader expects — each one is a whole row, with its own title, an optional
|
||||
subtitle underneath, and the editing widget on the right where a finger expects it.
|
||||
|
||||
**There is no Apply button, deliberately.** A preferences row applies as it changes, which is the
|
||||
practice these widgets are designed around and why they carry an `apply` of their own (AdwEntryRow's
|
||||
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
|
||||
batching). The two settings that cannot take effect immediately — the cache directory and relative
|
||||
time, both of which are planned before any source is known — say so in their subtitles instead of
|
||||
being held back by a button.
|
||||
|
||||
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
|
||||
and writes it back on every change. This file is the shape, that is the state.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwPreferencesDialog" id="preferences">
|
||||
<property name="title" translatable="yes">Preferences</property>
|
||||
<property name="search-enabled">0</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
<property name="title" translatable="yes">Event cache</property>
|
||||
<property name="icon-name">udpscope-events-symbolic</property>
|
||||
<property name="description" translatable="yes">Latches are written here as they happen; whether anything is written at all is the trigger panel's own switch.</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="dir_row">
|
||||
<property name="title" translatable="yes">_Directory</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="tooltip-text" translatable="yes">One directory in which every source gets a folder of its own, never one directory between two sources</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSpinRow" id="keep_row">
|
||||
<property name="title" translatable="yes">Keep _events</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="subtitle" translatable="yes">Per source; 0 keeps every event, which is a decision about the disk</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">0</property>
|
||||
<property name="upper">100000</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="value">32</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
<property name="title" translatable="yes">Plot</property>
|
||||
<property name="icon-name">udpscope-measure-symbolic</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<child>
|
||||
<object class="AdwSwitchRow" id="relative_row">
|
||||
<property name="title" translatable="yes">_Relative time</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="subtitle" translatable="yes">Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known.</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwComboRow" id="theme_row">
|
||||
<property name="title" translatable="yes">_Theme</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="subtitle" translatable="yes">What the interface looks like; auto follows the system</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
<property name="title" translatable="yes">Reset</property>
|
||||
<property name="icon-name">view-refresh-symbolic</property>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<child>
|
||||
<object class="AdwButtonRow" id="reset_row">
|
||||
<property name="title" translatable="yes">_Reset to defaults</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="start-icon-name">view-refresh-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,333 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The shortcut overview, as a UI file instead of a couple of hundred lines of widget construction, and
|
||||
in gedit's arrangement: one GtkShortcutsWindow holding a section per page (the window draws the
|
||||
sidebar and the search box over them), each section holding a group, each group holding rows.
|
||||
|
||||
The mouse rows are the reason this file exists. An accelerator cannot spell "Ctrl and drag", and
|
||||
libadwaita's AdwShortcutsItem, the modern replacement for this widget, has no icon property at all,
|
||||
so its cell reads "No Shortcut". GtkShortcutsShortcut has exactly one gesture type whose *icon* is
|
||||
drawn (shortcut-type "gesture" plus the icon property, per its own documentation), and that is what
|
||||
puts the mouse glyph where the keycaps would otherwise be.
|
||||
|
||||
Keep this file in step with the tables in shortcuts.cpp: those are what the application handles,
|
||||
this is what it shows. The `[help]` probe's `ui=` against `tables=` is the guard against the two
|
||||
drifting apart.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkShortcutsWindow" id="help_overlay">
|
||||
<property name="modal">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Acquisition</property>
|
||||
<property name="title" translatable="yes">Acquisition</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">space</property>
|
||||
<property name="title" translatable="yes">Pause or resume the display</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">a</property>
|
||||
<property name="title" translatable="yes">Arm or disarm the trigger</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">t</property>
|
||||
<property name="title" translatable="yes">Show or hide the trigger panel</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Panes and editors</property>
|
||||
<property name="title" translatable="yes">Panes and editors</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">c</property>
|
||||
<property name="title" translatable="yes">Show or hide the cursors pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">m</property>
|
||||
<property name="title" translatable="yes">Show or hide the measurements pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">g</property>
|
||||
<property name="title" translatable="yes">Show or hide the math pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">l</property>
|
||||
<property name="title" translatable="yes">Show or hide the layout editor</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">s</property>
|
||||
<property name="title" translatable="yes">Show or hide the side panel</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Files and windows</property>
|
||||
<property name="title" translatable="yes">Files and windows</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>e</property>
|
||||
<property name="title" translatable="yes">Export the visible range</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">F1</property>
|
||||
<property name="title" translatable="yes">Open the help window</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>slash</property>
|
||||
<property name="title" translatable="yes">Open this shortcut overview</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>q</property>
|
||||
<property name="title" translatable="yes">Quit udpscope</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Plot keyboard</property>
|
||||
<property name="title" translatable="yes">Plot keyboard</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">plus minus</property>
|
||||
<property name="title" translatable="yes">Zoom in or out, both axes</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>plus <Control>minus</property>
|
||||
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Alt>plus <Alt>minus</property>
|
||||
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Left Right</property>
|
||||
<property name="title" translatable="yes">Pan along time</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Up Down</property>
|
||||
<property name="title" translatable="yes">Pan along values</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">f</property>
|
||||
<property name="title" translatable="yes">Fit the view to the data</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Escape</property>
|
||||
<property name="title" translatable="yes">Cancel the gesture in progress</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Plot mouse</property>
|
||||
<property name="title" translatable="yes">Plot mouse</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom about the pointer, both axes</property>
|
||||
<property name="subtitle" translatable="yes">Wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||
<property name="subtitle" translatable="yes">Shift and wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||
<property name="subtitle" translatable="yes">Ctrl and wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Pan</property>
|
||||
<property name="subtitle" translatable="yes">Drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on time</property>
|
||||
<property name="subtitle" translatable="yes">Ctrl and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on values</property>
|
||||
<property name="subtitle" translatable="yes">Alt and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on time and values</property>
|
||||
<property name="subtitle" translatable="yes">Shift and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The signals panel: a heading, the hint that says what a signal is for, and the list they go in.
|
||||
|
||||
The rows are not here. There is one per signal in the stream, it carries the switch that decides whether
|
||||
that signal is acquired, its colour, its calibration and its drop targets, and the whole list is rebuilt
|
||||
whenever the stream's signals change — so the file holds the frame and the code holds the rows.
|
||||
|
||||
The list's size request is **width only**, deliberately. A height floor here made the list occupy 120 px
|
||||
whatever it held, which hid the compact rows' height behind it and wasted the space on an empty stream;
|
||||
the scroller this panel sits in already caps how tall it can grow.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkBox" id="signal_panel">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-top">8</property>
|
||||
<property name="margin-bottom">8</property>
|
||||
<property name="margin-start">8</property>
|
||||
<property name="margin-end">8</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="signals_heading">
|
||||
<property name="label" translatable="yes">Signals</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="signals_hint">
|
||||
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox" id="signal_list">
|
||||
<property name="selection-mode">none</property>
|
||||
<property name="width-request">260</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
What to watch: one page of sources it can connect to, one page of waveforms opened before, and the
|
||||
three buttons that are about the chooser rather than about a source.
|
||||
|
||||
This window exists *before* the application does. `main()` asks it what to open and only then builds
|
||||
the model and the rest of the interface, which is why the preferences are reachable from here at all
|
||||
— and why nothing in this file may reach for `App`.
|
||||
|
||||
The two pages are an `AdwViewStack` under an `AdwViewSwitcherBar` and not a `GtkNotebook`: a tab strip
|
||||
is the wrong shape for two views of one thing, and the switcher is what current libadwaita asks for.
|
||||
The bar is revealed unconditionally because this window is small enough that it always is narrow.
|
||||
|
||||
The rows are not here, deliberately. A source row and a waveform row are *data* — `source_dialog.cpp`
|
||||
builds them from the known hosts and the recent files, and rebuilds them whenever either changes — so
|
||||
this file holds the frame and the fixed buttons, and nothing here carries a value.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwWindow" id="chooser_window">
|
||||
<property name="title" translatable="yes">udpscope — choose a source</property>
|
||||
<property name="default-width">420</property>
|
||||
<property name="default-height">360</property>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="prefs_button">
|
||||
<property name="label" translatable="yes">_Preferences…</property>
|
||||
<property name="use-underline">1</property>
|
||||
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="cancel_button">
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="use-underline">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="new_host_button">
|
||||
<property name="label" translatable="yes">_New host…</property>
|
||||
<property name="use-underline">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="AdwViewStack" id="page_stack">
|
||||
<child>
|
||||
<object class="AdwViewStackPage">
|
||||
<property name="name">sources</property>
|
||||
<property name="title" translatable="yes">Sources</property>
|
||||
<property name="icon-name">network-transmit-receive-symbolic</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="sources_list">
|
||||
<property name="activate-on-single-click">1</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwViewStackPage">
|
||||
<property name="name">waveforms</property>
|
||||
<property name="title" translatable="yes">Waveforms</property>
|
||||
<property name="icon-name">document-open-recent-symbolic</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">1</property>
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="waves_list">
|
||||
<property name="activate-on-single-click">1</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="halign">end</property>
|
||||
<property name="margin-top">6</property>
|
||||
<property name="margin-bottom">6</property>
|
||||
<property name="margin-end">12</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="waves_open">
|
||||
<property name="label" translatable="yes">_Open…</property>
|
||||
<property name="use-underline">1</property>
|
||||
<property name="tooltip-text" translatable="yes">choose an exported .npy to view and analyse</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
<child type="bottom">
|
||||
<object class="AdwViewSwitcherBar" id="page_switcher">
|
||||
<property name="reveal">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,140 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The form a source is added or edited with: one dialog, two groups, eight rows.
|
||||
|
||||
A grid of labels and bare entries is what this was, and a grid is the wrong shape for it. Each row
|
||||
here is a whole row — a title, a subtitle under it where the hint used to be a placeholder, and the
|
||||
editing widget on the right — so the form reads the way every GNOME application's does, and the hints
|
||||
survive being typed over (a placeholder does not: it disappears the moment there is a value, which is
|
||||
exactly when a user may want to know what empty meant).
|
||||
|
||||
The action is in the header bar rather than a button row at the foot: it is what the dialog is *for*,
|
||||
it is what the keyboard's Enter reaches, and its label says which mode the form is in — `Connect` for
|
||||
a new host, `Save` for an existing configuration, which `source_dialog.cpp` sets.
|
||||
|
||||
Nothing here carries a value. The dialog is filled from the source being edited, or from the command
|
||||
line for a new one, by the code that opens it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwDialog" id="source_form">
|
||||
<property name="content-width">420</property>
|
||||
<property name="content-height">560</property>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="form_title">
|
||||
<property name="title" translatable="yes">New host</property>
|
||||
</object>
|
||||
</property>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="form_cancel">
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="use-underline">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="form_apply">
|
||||
<property name="label" translatable="yes">Conn_ect</property>
|
||||
<property name="use-underline">1</property>
|
||||
<property name="receives-default">1</property>
|
||||
<property name="css-classes">suggested-action</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="propagate-natural-height">1</property>
|
||||
<property name="child">
|
||||
<object class="AdwPreferencesPage">
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Connection</property>
|
||||
<property name="description" translatable="yes">Leave the multicast group and the interface empty for unicast on the default route</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="server_row">
|
||||
<property name="title" translatable="yes">_Server</property>
|
||||
<property name="use-underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSpinRow" id="port_row">
|
||||
<property name="title" translatable="yes">_Port</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">1</property>
|
||||
<property name="upper">65535</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="value">8000</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="group_row">
|
||||
<property name="title" translatable="yes">Multicast _group</property>
|
||||
<property name="use-underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="iface_row">
|
||||
<property name="title" translatable="yes">_Interface</property>
|
||||
<property name="use-underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Configuration</property>
|
||||
<property name="description" translatable="yes">The name is what this source's session is called. The buffer and the clock are decided before the first socket is opened.</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="name_row">
|
||||
<property name="title" translatable="yes">_Name</property>
|
||||
<property name="use-underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="note_row">
|
||||
<property name="title" translatable="yes">N_ote</property>
|
||||
<property name="use-underline">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSpinRow" id="buffer_row">
|
||||
<property name="title" translatable="yes">_Buffer MB</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">1</property>
|
||||
<property name="upper">65536</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="value">64</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSwitchRow" id="form_relative_row">
|
||||
<property name="title" translatable="yes">_Relative time</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="subtitle" translatable="yes">plot time from the packet counter and the declared rate, instead of the stream's own time signal</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,276 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The trigger panel's frame: the heading, one labelled row per setting, the two buttons that run it, and
|
||||
the search block.
|
||||
|
||||
Every row here is a label and a place for a control, and the control itself is built in code. A
|
||||
dropdown's model and a spin button's range are the *application's* (the condition list, the level
|
||||
range that follows the channel's scale), while a label is text a translator owns, so the split runs
|
||||
between them: the row is the file's and the control is appended into it. That is the arrangement
|
||||
`labeled_box()` used to make in code, with the label moved where all the other labels are.
|
||||
|
||||
Between the controls, the labels and the search block are what "read the panel" means, so they are
|
||||
here: which of these rows `sync_trigger_rows()` shows depends on the condition (a pulse width has a
|
||||
width, a window has two levels, a dropout has a timeout), and that is the code's decision, not this
|
||||
file's — this file just has to have a row for each of them to show or hide.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkBox" id="trigger_panel">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<property name="margin-top">8</property>
|
||||
<property name="margin-bottom">8</property>
|
||||
<property name="margin-start">8</property>
|
||||
<property name="margin-end">8</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="trigger_heading">
|
||||
<property name="label" translatable="yes">Trigger</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="signal_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">signal</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="condition_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">condition</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="edge_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">edge</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="cmp_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">width is</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="width_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">width s</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="width2_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">and s</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="level2_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">to level</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="timeout_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">timeout s</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="level_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">level</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="pre_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">pre-trigger %</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="holdoff_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">hold-off s</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="mode_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">mode</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="record_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="record_label">
|
||||
<property name="label" translatable="yes">record events</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="trigger_arm">
|
||||
<property name="label" translatable="yes">Arm</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="trigger_live">
|
||||
<property name="label" translatable="yes">Go live (trigger off)</property>
|
||||
<property name="tooltip-text" translatable="yes">disable trigger mode and stream continuously</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkExpander" id="search_exp">
|
||||
<property name="label" translatable="yes">Search the buffer</property>
|
||||
<property name="tooltip-text" translatable="yes">find every place in the retained buffer where the condition above holds, and step through them</property>
|
||||
<property name="child">
|
||||
|
||||
<object class="GtkBox" id="search_sbox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">4</property>
|
||||
<property name="margin-top">4</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="search_run">
|
||||
<property name="label" translatable="yes">Search</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="search_cancel">
|
||||
<property name="label" translatable="yes">Stop</property>
|
||||
<property name="sensitive">0</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="search_status">
|
||||
<property name="label" translatable="yes">not searched</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="wrap">1</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="search_prev">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">the previous hit</property>
|
||||
<property name="sensitive">0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="search_pos">
|
||||
<property name="label">--</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="search_next">
|
||||
<property name="icon-name">go-next-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">the next hit</property>
|
||||
<property name="sensitive">0</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="search_marks">
|
||||
<property name="label"> </property>
|
||||
<property name="halign">start</property>
|
||||
<property name="css-classes">dim-label</property>
|
||||
<property name="ellipsize">3</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The application's UI files, compiled into the binary beside the icons and the help pages, so there
|
||||
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
|
||||
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
|
||||
not open, which is a silent failure at runtime and a loud one here.
|
||||
-->
|
||||
<gresources>
|
||||
<gresource prefix="/org/udpscope/ui">
|
||||
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
||||
<file preprocess="xml-stripblanks">preferences.ui</file>
|
||||
<file preprocess="xml-stripblanks">help.ui</file>
|
||||
<file preprocess="xml-stripblanks">source-form.ui</file>
|
||||
<file preprocess="xml-stripblanks">source-chooser.ui</file>
|
||||
<file preprocess="xml-stripblanks">main-window.ui</file>
|
||||
<file preprocess="xml-stripblanks">cursors-panel.ui</file>
|
||||
<file preprocess="xml-stripblanks">measures-panel.ui</file>
|
||||
<file preprocess="xml-stripblanks">math-panel.ui</file>
|
||||
<file preprocess="xml-stripblanks">math-editor.ui</file>
|
||||
<file preprocess="xml-stripblanks">events-pane.ui</file>
|
||||
<file preprocess="xml-stripblanks">header-bar.ui</file>
|
||||
<file preprocess="xml-stripblanks">trigger-panel.ui</file>
|
||||
<file preprocess="xml-stripblanks">signals-panel.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
+1385
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The cursors pane: what the two vertical markers and the per-row rulers read, in the four cursor\n// modes, rebuilt only when the rows or the mode change.
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
|
||||
/// `%.6g s` is exact enough for a cursor readout and short enough for the panel.
|
||||
void update_cursors_pane(App& app) {
|
||||
if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return;
|
||||
if (app.cursors_pane_mode != static_cast<int>(app.rows->cursor_mode()) ||
|
||||
app.cursors_pane_rows != app.rows->row_count() ||
|
||||
app.cursors_pane_generation != app.model->generation() ||
|
||||
app.cursors_pane_bound != bound_slot_mask(app)) {
|
||||
rebuild_cursors_pane(app);
|
||||
}
|
||||
const double t0 = app.rows->cursor_a_x();
|
||||
const double t1 = app.rows->cursor_b_x();
|
||||
const auto put = [](GtkWidget* w, const std::string& text) {
|
||||
if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str());
|
||||
};
|
||||
|
||||
const RowSet::CursorMode mode = app.rows->cursor_mode();
|
||||
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
|
||||
const bool crossings = mode == RowSet::CursorMode::Mix;
|
||||
for (const App::CursorLine& line : app.cursors_lines) {
|
||||
if (line.a == nullptr) continue;
|
||||
// One measurement per cell, each with the unit its own axis declares: the pane is a
|
||||
// readout beside a plot, where the values are the point.
|
||||
std::string va;
|
||||
std::string vb;
|
||||
std::string vd;
|
||||
if (line.slot == -2) {
|
||||
va = format_measure(t0, "s");
|
||||
vb = format_measure(t1, "s");
|
||||
vd = format_measure(t1 - t0, "s");
|
||||
} else if (line.slot == -1 && rulers) {
|
||||
const std::string unit = row_unit(app, line.row);
|
||||
const double y1 = app.rows->ruler_y(line.row);
|
||||
const double y2 = app.rows->ruler_b_y(line.row);
|
||||
va = format_measure(y1, unit);
|
||||
vb = format_measure(y2, unit);
|
||||
vd = format_measure(y2 - y1, unit);
|
||||
} else if (crossings && line.slot >= 0) {
|
||||
// Where each cursor crosses this trace: the value the trace has at that time,
|
||||
// read the way a crosshair reads it.
|
||||
// The same call the crossing markers use, on the same trace: the pane and the
|
||||
// marks on the plot cannot disagree about what the value is.
|
||||
const RowSet::Row& row = app.rows->row(line.row);
|
||||
double y1 = 0.0;
|
||||
double y2 = 0.0;
|
||||
const bool a = row.plot != nullptr &&
|
||||
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t0, y1);
|
||||
const bool b = row.plot != nullptr &&
|
||||
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t1, y2);
|
||||
if (a && b) {
|
||||
const std::string unit = slot_unit(app, line.row, line.slot);
|
||||
va = format_measure(y1, unit);
|
||||
vb = format_measure(y2, unit);
|
||||
vd = format_measure(y2 - y1, unit);
|
||||
} else {
|
||||
va = vb = vd = a || b ? "partial" : "--";
|
||||
}
|
||||
} else {
|
||||
va = vb = vd = "--";
|
||||
}
|
||||
put(line.a, va);
|
||||
put(line.b, vb);
|
||||
put(line.d, vd);
|
||||
}
|
||||
}
|
||||
|
||||
GtkWidget* build_cursors_panel(App& app) {
|
||||
// The panel's shape is a UI file; the *contents* of its grid are not, because they follow the cursor
|
||||
// mode and the rows and are rebuilt whenever either changes.
|
||||
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/cursors-panel.ui");
|
||||
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_panel"));
|
||||
app.cursors_mode_drop = GTK_WIDGET(gtk_builder_get_object(ui, "mode_drop"));
|
||||
app.cursors_reset = GTK_WIDGET(gtk_builder_get_object(ui, "reset"));
|
||||
app.cursors_body = GTK_WIDGET(gtk_builder_get_object(ui, "body"));
|
||||
if (box == nullptr || app.cursors_mode_drop == nullptr || app.cursors_reset == nullptr ||
|
||||
app.cursors_body == nullptr) {
|
||||
// A missing id is a mistake in the `.ui`. The panel comes up empty rather than taking the window
|
||||
// with it, and `[cursors]` reads differently rather than the application failing to start.
|
||||
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
}
|
||||
|
||||
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
||||
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
||||
kCursorModeNames[3], nullptr};
|
||||
gtk_drop_down_set_model(GTK_DROP_DOWN(app.cursors_mode_drop),
|
||||
G_LIST_MODEL(gtk_string_list_new(names)));
|
||||
gtk_widget_set_tooltip_text(app.cursors_mode_drop,
|
||||
"vertical: two times, shared by every row\n"
|
||||
"horizontal: one value per row, at the two times\n"
|
||||
"both: the two times and one value per row\n"
|
||||
"mix: the two times, read off each trace where they cross");
|
||||
g_signal_connect(app.cursors_mode_drop, "notify::selected",
|
||||
G_CALLBACK(on_cursor_mode_changed), &app);
|
||||
|
||||
// Reset lives beside the mode it resets rather than in the overflow menu: it acts on this pane's
|
||||
// instruments and is only visible while they are on, both of which are this pane's facts.
|
||||
gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges");
|
||||
g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
const bool on = gtk_toggle_button_get_active(button);
|
||||
app->rows->set_cursors_visible(on);
|
||||
// The reset button exists for the cursors: with them hidden there is nothing to
|
||||
// reset, and a button that does nothing reads as broken.
|
||||
gtk_widget_set_visible(app->cursors_reset, on);
|
||||
// The pane is the cursors' readout: with them off there is nothing to read.
|
||||
if (app->cursors_revealer != nullptr) {
|
||||
gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on);
|
||||
}
|
||||
if (on) {
|
||||
app->rows->reset_cursors();
|
||||
rebuild_cursors_pane(*app);
|
||||
update_cursors_pane(*app);
|
||||
}
|
||||
}
|
||||
|
||||
void on_cursors_reset(GtkButton*, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
// A reset the user cannot see reads as a dead button, so it also switches the
|
||||
// cursors on.
|
||||
if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true);
|
||||
}
|
||||
app->rows->reset_cursors();
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user