GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document.
155 lines
10 KiB
Markdown
155 lines
10 KiB
Markdown
# 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.
|
||
|
||
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"
|
||
```
|