# 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 ./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port ``` `--help` lists the registered source kinds. 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. The manual has its own check, the same shape: ```sh cmake --build build --target test-help ``` That runs `yelp-check`'s four offline subcommands over `help/C//` (links, media, orphans, ids) and renders the whole set with `yelp-build html` into a temp directory, so the manual is checked the way the `.ui` files are. **`yelp-check validate` is deliberately not used**: it fetches the Mallard RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and not for a fault in the pages. `yelp-check`/`yelp-build` are found at configure time; without them the target is absent rather than silently passing. The manual is a Mallard set under `help/C//`, installed by an `install()` rule to `/help/C//` — where the freedesktop Help System Specification's `help:` URI resolves `help:` — so it is *read from a datadir by another process* even though the binary itself has no install step. `F1` opens it in Yelp through that URI. The document identifier is the desktop file's basename (`data/org.udpscope.app.desktop.in` → `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install directory at once. The figures are regenerated by `tools/make-help-shots.sh`, which drives the app headlessly against the mock and draws the annotation boxes with ImageMagick; Mallard is translatable with `itstool`, but only the `C` set is authored. Because the manual is the only thing udpscope installs, it can be installed *alone* — where `help:` finds it, and without the `cmake --install` of a whole project — by either of two opt-in targets. Neither is built by default and `cmake --build build` never touches either: ```sh cmake --build build --target install-help-user # /help/C//, no root cmake --build build --target install-help # /share/help/C// ``` `install-help-user` writes to `$XDG_DATA_HOME` when it is set and to `$HOME/.local/share` when it is not — the XDG Base Directory Specification's own default for the variable, and the fallback `help_installed_path()` uses — so it needs no root and lands the set exactly where `F1` and the alert look for it. `install-help` goes through the install machinery into the configured `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at `/usr` with `-DCMAKE_INSTALL_PREFIX` at configure time. Neither target can install system-wide by itself: a build target has no more privilege than the shell that ran it, so `install-help` writes to the configured prefix and a system-wide caller wraps it in `sudo`. Both print where they wrote. Both land the same tree the `install()` rule does — the user-local copy applies that rule's file filter, and the prefix-wide target *is* that rule, reached through the `Help` component (`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every component, so its behaviour is unchanged. Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; `--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same 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. `--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** — before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing is exported when the option is absent, so GTK's own choice stands. `--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional (`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel, `ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10, `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is required; a MAC with no `address` is looked up by broadcast, or by a sweep of `subnet` (`a.b.c`). The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S` switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. ```sh python3 tools/mock_e1608.py --port 54711 & ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 ``` `--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest decimation and spaces the samples within a frame), `endian` `little|big`, and `signals` `name:type[count],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64` (a count above 1 makes a vector signal: one channel whose `count` values per datagram are consecutive samples, as a UDPStreamer block signal), e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A datagram of any other size than the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as `[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`, `--log FILE`). ```sh python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Wave:int16[4] & ./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \ --source-opt signals=Trigger:uint8,Ia:float32,Wave:int16[4] ``` `tools/mock_udpstreamer.py` has waveform-injection options used by the probes: - `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the default 30, i.e. `y = ±1.179`) and then rings — a band-limited edge, which is what a crossing count or a pulse-width test has to survive. The series is deterministic and the odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger, the search and the glitch's inverted pulse still read where they did on the step; the *extremes* are what move. `0` (or negative) is the ideal ±1 step, which is what a probe asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is the one that does, because its `want` is the analytic `tau*ln2` of a step (see [verification.md](verification.md)). It costs the producer about 0.5 ms per 2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock delivers at roughly nine tenths of its declared pace — a reading that scales with the retained span reads that much lower, and nothing else does. - `--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. - `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`, `noise`) are untouched, which is what lets a probe compare a spiked channel against a clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is added to `sine`; the sign alternates per period because a one-signed train is a DC offset a median would partly track rather than reject. `N` is in **samples**, not seconds, so its duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's ~65507-byte payload limit, and the mock dies on its first `sendto` with `OSError: [Errno 90] Message too long` while the run still exits 0. 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. `plot::data::RingBuffer` carries two writes a plain SPSC ring has no use for and this app does: `amend(absolute, v)` rewrites a retained slot when a late packet's data finally arrives, and `clear_to(absolute)` empties a ring and **resumes its count at a given origin**. The second exists because a math channel's ring is recomputed from its inputs' *retained* window: `clear()` would restart it at 0 and leave every absolute index — `base()`, `produced()`, `copy_from()` — short by that window's base, which is the desynchronisation `live_window_locked()` then reads as an empty window (see [invariants-math-channels.md](invariants-math-channels.md)). Its guards are `ring_clear_to_resumes_the_absolute_count` — a derived ring refilled from a source's retained window must come back with the source's own `produced()`/`base()` — and `ring_clear_to_zero_is_clear`. 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" ```