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`.
214 lines
14 KiB
Markdown
214 lines
14 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.
|
||
|
||
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"
|
||
```
|