A TCP frame is never fragmented; a real TCPStreamer cycle (240 KB) exceeded the UDP datagram limit and made the client reconnect forever, seeing CONFIG but never DATA. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
391 lines
28 KiB
Markdown
391 lines
28 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
|
||
./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/<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.
|
||
|
||
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 # <data-home>/help/C/<id>/, no root
|
||
cmake --build build --target install-help # <CMAKE_INSTALL_PREFIX>/share/help/C/<id>/
|
||
```
|
||
|
||
`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-samples` (samples
|
||
per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two),
|
||
`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row),
|
||
`--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,
|
||
`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `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 a broadcast sent on every interface (`Scan`), 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`); the mock stands in for the board (a real board has been run once, with one channel; see
|
||
the README), 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][w],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`, e.g.
|
||
`Trigger:uint8,Ia:float32,Wave:float32[16]`. A count above 1 makes a vector signal, shown one of two ways: by
|
||
default as `count` **channels** `Wave[0]`..`Wave[15]` of one sample per cycle each (batched like any scalar
|
||
list); with a trailing `w` (`Wave:float32[16]w`) as ONE **waveform** channel receiving `count` consecutive
|
||
samples per cycle at `rate * count`, as a UDPStreamer block signal. A waveform row needs a count above 1, a
|
||
list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most
|
||
256 channels (a scalar 1, a `channels` row `count`, a waveform 1). 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]'
|
||
```
|
||
|
||
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
|
||
the datagram is the same, `w` only says how the app shows it.)
|
||
|
||
`--source tcps` receives a MARTe2 `TCPStreamer` (standalone) or `TCPStreamService` (named stream) over
|
||
TCP: `server` (default 127.0.0.1), `port` 44500, and `stream` (optional; a `SUBSCRIBE` frame is sent only when it is
|
||
set). `tools/mock_tcpstreamer.py` serves one client with the `mock_udpstreamer.py` signals (`--port`, `--stream`
|
||
to require a `SUBSCRIBE`, `--rate`, `--batch`, `--freq`, `--close-after S`, `--drop-every N`).
|
||
|
||
```sh
|
||
python3 tools/mock_tcpstreamer.py --port 44500 --stream fast &
|
||
./build/udpscope --source tcps --source-opt port=44500 --source-opt stream=fast
|
||
```
|
||
|
||
`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 a TCP
|
||
transport mode (item 6 below) and 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. Six 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=`.
|
||
6. **A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP
|
||
connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (a 3 s connect
|
||
bound through `SO_SNDTIMEO`, a 1 s receive bound, an optional `SUBSCRIBE` frame,
|
||
`UDPS_PKT_SUBSCRIBE = 5`, sent only when `stream_name` is set), the existing
|
||
`read_tcp_frame()` for the frames, the reorder window forced to 0 (TCP is ordered, so a
|
||
counter gap is a server-side drop and is reported at once), multicast forced off, no
|
||
DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), no keepalive ACK
|
||
and no datagram tap. The receive buffer is `max_packet_bytes + header` (1 MiB by default,
|
||
matching the server's frame ceiling) in TCP mode, since a TCP frame is never fragmented and
|
||
a MARTe cycle of large arrays is one frame; it is `UDPS_MAX_DATAGRAM_BYTES` only for UDP. A
|
||
stream name over 255 bytes is refused at create.
|
||
|
||
`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.
|
||
|
||
A figure row can hold **several plots side by side**, which the host's horizontal split needs and
|
||
the library had no concept of: `Figure(std::span<const u32> cells_per_row)` builds one, with
|
||
`plot_count()`, `cells_in_row()`, `first_plot_of_row()` and `row_of_plot()` to move between rows and the
|
||
flattened plot index, `set_cell_weights()` and `set_col_gap()` to size the cells, and `FigureLayout::cells`
|
||
(one rect per plot, flattened) and `FigureLayout::col_gap` to read the result back. `Figure(u32 rows)`
|
||
still means one cell per row, so every row-only caller and test is unchanged, and `row_count()` stays the
|
||
number of *figure rows* (the host's `RowSet::row_count()` is the plot count, `figure_row_count()` the
|
||
rows). `plot(i)`, `link_x()` and the crosshair's hovered index all take the **flattened** plot index.
|
||
Cell widths use the height scheme: a floor of **64 px logical** per cell, the weights share the rest,
|
||
the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells
|
||
proportionally and drops the column gap. The x labels and title follow the **column structure**: a row
|
||
hides its x strip (`XStrip::None`) only when the row directly below has the *same columns* (same cell
|
||
count, same normalised cell weights within 1e-4, empty weights = equal), so `[1][2]/[3][4]` has an axis
|
||
under 3 and 4 only while `[1][2]/[3]` has one under 1, 2 and 3; the rule is structural, transitive down a
|
||
chain and decided before the measurement pass (`same_columns()` in `figure.cpp`); `set_x_labels_only_on_bottom(false)`
|
||
labels every row. The left y gutter stays one maximum over the whole figure.
|
||
|
||
The cells of a figure row can **share one y range**, symmetric to the x link
|
||
(`docs/superpowers/specs/2026-11-03-split-shared-y-design.md`):
|
||
|
||
- `Figure::set_share_row_y(bool)`, default off so every existing figure and `tests/test_figure.cpp`
|
||
read as before; the host turns it on in `RowSet::rebuild_all()` before the cells are populated. When
|
||
on, the cells of each figure row form an implicit y link group, rebuilt with the cells.
|
||
- Link semantics: `layout()` runs a `sync_y_links()` that adopts a member's changed `y_range` as the
|
||
group's and pushes it to the others, so a wheel, pan, box zoom or axis drag (all `set_y_range`) needs
|
||
no controller change; a manual `set_y_range` on a member clears the group's auto flag. A member's own
|
||
autoscale is suppressed and the group owns the flag: it fits to the union of its members' visible
|
||
traces (cells with no trace are ignored; a log axis takes the union in axis space), and `fit_all` and
|
||
a double-click fit the group, not one cell. The group only follows on a laid-out frame, so a caller
|
||
that sets a range or a fit and reads it back needs one `layout()` in between.
|
||
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`: `None` forces the y label
|
||
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the first
|
||
of its row, structurally from the column index and never from content, and skips `None` cells in the
|
||
common-gutter maximum. A y axis drag can therefore start only in the leftmost cell.
|
||
- **Cell placement is by plot width**: the row's y gutter comes off the row width first, then the cells
|
||
share the rest by the existing `floor + extra * w / total` rule, the floors applying to plot widths.
|
||
Equal weights give equal plot areas; the first cell's rect is gutter plus its plot width and the rects
|
||
still tile the row exactly.
|
||
- **The right gutter belongs to the last cell.** In a shared-y split row only the last cell gets
|
||
`set_right_gutter()`; it is taken off the row beside the y gutter (capped so both fit in half the row
|
||
and never beyond the figure's right gutter) and the last cell's rect grows right by it. The gap between
|
||
two plot frames is then exactly `col_gap` rather than `col_gap` plus a gutter; unsplit rows and rows
|
||
with sharing off lay out as before (`figure_split_row_gap_*`, `figure_row_gutter_without_sharing_is_the_old_layout`).
|
||
|
||
In the GTK4 and GLFW adapters the pointer is routed by **cell rect** (`row_in(rows, x, y)`: the
|
||
containing cell, else the nearest by squared distance), one `input::Controller` per plot. The tests are
|
||
the `figure_cell*` cases in `tests/test_figure.cpp` and `glfw_host_routes_by_cell`.
|
||
|
||
`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"
|
||
```
|