Files
udpscope/docs/claude/build-and-run.md
T
Martino Ferrari c1460a6977 feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow`
built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references
and no translation, and it kept the manual in the *binary* when every other desktop application's is
a document on disk. It is now a real Mallard set:

  help/C/org.udpscope.app/  index.page + 14 topic pages + figures/*.png

and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:`
scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a
`GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the
page table and `help_self_check()`'s markup validation are gone with the resource.

The document identifier is the desktop file's own basename (its desktop-file id), derived in
CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the
GApplication id and the install directory at once. The set is installed by a new `install()` rule to
`<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:`
URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the
binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that
one place.

The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page
(what a search result shows), `<steps>` for procedures, `<terms>` for option lists,
`<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph
via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by
`tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and
draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx`
dump reading, the header and status bands come from a documented table, and a crop whose standard
deviation is 0 fails the script.

`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages.

The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over
in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line
reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which
is the finding the old count named.

Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings`
is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four
subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at
`share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...`
with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb,
activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its
rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders
the whole set).

Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline
in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]`
line in `probe-readings.md`.
2026-10-14 16:49:30 +02:00

214 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
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/<id>/` (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/<id>/`, installed by an `install()` rule to
`<datadir>/help/C/<id>/` — where the freedesktop Help System Specification's `help:` URI resolves
`help:<id>` — 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.
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.
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
- `--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.
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"
```