Commit Graph
8 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 1627abcd4a docs: the sample budget in samples per signal, the memory limit and the planner probe lines
Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples,
records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the
buffer: and planner: dump lines and the new Preferences row, and updates the help
pages and the help-shot script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:49:21 +01:00
Martino Ferrari ac054a30c9 fix: bound the density grid, and make [notrace] a reading instead of a flood
The plotter caps a density grid at kDensityCellCap now (deps/plotter), so a 2 s
window with persistence on no longer scales the image with the window: the
probe's four one-signal rows read 1 044 714 cells / 4 178 856 bytes where they
read 2 226 150 / 8 904 600, and a moved frame uploads 1 044 714 bytes where it
uploaded 2 226 150. The probe's own legs are unchanged (on peak=1.0000,
step-acquisitions peak=1.5000, step-time ratio=0.367879).

The paint probe no longer counts a frame the density is drawing as *traceless*.
`Renderer::draw_traces()` returns while the ink is the image, so every frame of
a persistence display stages no trace: the dump read `traceless paints: 653
(637 after the first bind)` for a run whose real figure is 4 (0), and the
`[notrace]` line printed for each of those frames. It is now behind
UDPSCOPE_PROBE_NOTRACE -- a per-paint line is a log flood and not a reading --
while the counters it details stay in every build, and the line stays reachable
for the state it was written for (a bound row that draws nothing).

Docs corrected where they stated the old rule: the README, the trigger
invariants, the density probe's readings, the verification list (the new
variable) and the manual page behind the panel's tooltip.
2026-10-16 21:09:11 +02:00
Martino Ferrari da826f2cb5 fix: persistence is the trace's own phosphor, not a density behind a stroke
The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:

- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
  Deriving it from the steady state is the gain offset the user reported between the latest
  acquisition and the persistent image; a signal crossing the same place every acquisition now settles
  at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
  holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
  makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
  over the four one-signal rows of the probe's run.

The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):

    [density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
    [density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
    [density-probe] step-acquisitions ... peak=1.5000
    [density-probe] time-before ... peak=2.1359
    [density-probe] step-time ... ratio=0.367879
    [density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
    [udpscope]   density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150

`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.

Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.

`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
2026-10-16 20:21:57 +02:00
Martino Ferrari 974d6aa2d9 trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
2026-10-16 17:28:54 +02:00
Martino Ferrari e14e1ee069 trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
2026-10-16 16:46:17 +02:00
Martino Ferrari fe499bfe41 help: the search block's (i) on its header, a 12 px mark, and a real search page 2026-10-14 23:29:00 +02:00
Martino Ferrari e140642ed0 fix: the manual's figures carry a description, and the check that would have caught them
Three pages opened in nothing in Yelp -- overview (twice), math, trigger -- while every check
passed. They are exactly the pages whose <figure> had a sentence for a <title> and no <desc>:
a Mallard <figure> is title?, desc?, media, and the page is refused without the description. The
idiomatic shape, as the shipped sets on this machine write it, moves the sentence into <desc> and
leaves the <title> a name. Note trigger-capture.png is referenced from both overview and trigger,
which is why those two pages are the ones that were noticed.

The gap was in the check, not in its reach: tools/check_help.sh ran links/media/orphans/ids and an
HTML render, and all four are silent about a page that is well linked, well parsed and
unrenderable. `yelp-check validate` is the one that would have caught it, and it cannot run here:
it generates the RelaxNG schema with xsltproc, but the schema includes the base grammar over the
network and this machine's libxml cannot fetch. So the structural rules this project has actually
needed are checked with xmllint's XPath, offline, each written after a page needed it.

One trap, paid for while writing it: a Mallard page declares
xmlns="http://projectmallard.org/1.0/", XPath 1.0 has no default namespace, so `//figure` matches
nothing and every rule passes -- the first version of this check reported "ok" for the very defect
it was written for. Every expression uses local-name() for that reason, and the script says so
where the next reader will be.

Verified: on the committed pages the rules exit 1 and name the three figures, which is the
failing-before case kept as the regression test; on the fixed pages all five rules pass and
yelp-build renders all 15; make -C build test-help goes from red to green.
2026-10-14 17:15:31 +02:00
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