From 1627abcd4a519bc2f2a5bc875e576917928cab1d Mon Sep 17 00:00:00 2001
From: Martino Ferrari
Date: Tue, 3 Nov 2026 02:49:21 +0100
Subject: [PATCH] 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
---
CLAUDE.md | 7 +-
README.md | 89 ++++++++++++-------
SPECS.md | 15 +++-
docs/claude/architecture.md | 5 +-
docs/claude/build-and-run.md | 6 +-
docs/claude/invariants-data-path.md | 6 +-
docs/claude/invariants-math-channels.md | 2 +-
docs/claude/invariants-measurements.md | 2 +-
docs/claude/invariants-session-and-export.md | 6 +-
docs/claude/invariants-trigger-and-pinning.md | 2 +-
docs/claude/probe-readings.md | 64 +++++++++----
docs/claude/verification.md | 30 +++----
help/C/org.udpscope.app/overview.page | 2 +-
help/C/org.udpscope.app/sessions.page | 6 +-
help/C/org.udpscope.app/source.page | 2 +-
help/C/org.udpscope.app/trigger.page | 2 +-
help/C/org.udpscope.app/window.page | 9 +-
todo.md | 2 +-
tools/make-help-shots.sh | 2 +-
19 files changed, 166 insertions(+), 93 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 17e9a5d..4eabd46 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered
-source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 200 MB), and draws it
+source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it
with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of
the live view it provides the things a bench scope provides: a trigger with a pre/post event
window, a pause that freezes the visible window, cursors, statistical measurements,
@@ -19,6 +19,11 @@ Two documents bound the work and are **not** interchangeable with this one:
- **`README.md`** documents what was actually *built*, including deliberate deviations from
the spec and known limitations. **Read it before concluding something is a bug.**
+**Deliberate deviation from `SPECS.md` line 1:** the sample buffer is budgeted in *samples per signal*
+(`--buffer-samples`, rounded down to a power of two), not in megabytes, under a separate hard
+`--memory-limit-mb` (also a Preferences row). There is no `--buffer-mb`; see README and `SPECS.md`'s
+"Deviations from the spec".
+
Three things shape almost every change here, and the linked files expand on each:
- **There are exactly two threads** — a producer that owns the data source and the GTK main
diff --git a/README.md b/README.md
index 60ef547..f181642 100644
--- a/README.md
+++ b/README.md
@@ -22,8 +22,9 @@ It is used for exactly one widget — the shortcut overview is an `AdwShortcutsD
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
replacement.
-Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
-(size of the sample buffer, default 200), `--max-window` (largest time
+Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples`
+(samples kept per acquired signal, default 10M; see "The sample buffer is budgeted in samples" below),
+`--memory-limit-mb` (hard ceiling on the ring storage, default 8192), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
@@ -33,16 +34,36 @@ added and removed from the **Layout editor** afterwards), `--relative-time` (see
`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
limitation below).
-**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
-buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
+**The sample buffer is budgeted in samples per signal, under a memory limit.** `--buffer-samples N`
+(`250000`, `64k`, `100M`, `1.5G`; k, M and G are decimal, a *sample* count and not a byte size) is how
+many samples to keep for **every acquired signal**, and for the shared time ring. A ring is a power of
+two, so the request is rounded **down** to one --- "up to" --- and the UI shows both figures: the
+default 10 M asks for, and gets, 8 388 608 per signal, and `--buffer-samples 100M` gets 67 108 864. What that
+costs in bytes follows from the channel count (`ticks x 8` for the clock, doubled in relative mode, plus
+`ticks x 4` per channel), and the status popover and the badge text quote the *planned* bytes rather than a
+figure the user typed. The ceiling on those bytes is a separate application setting,
+**`--memory-limit-mb`** (default 8192 MiB; also a *Memory limit* row in **Preferences**, which re-plans the
+live buffer when changed). `plan_partition()` starts at the power-of-two floor of the request and
+**halves** it until the rings fit under the limit, and throws only when 128 samples do not fit. A clipped
+plan is never silent: stderr gets one line (`[udpscope] buffer limited to 8.39 M samples per signal (100 M
+requested) by the 256 MiB memory limit; the rings take 192 MiB`), the status popover's buffer row names the
+limit, and the source form's note says so, assuming one signal there because the channel count is not
+known before a stream is. `--buffer-mb` and the stored `buffer_mb` key are gone, with no compatibility
+shim: the flag is an unknown argument, and an old `buffer_mb` in a saved configuration is ignored (the
+source falls back to the default until it is saved again as `buffer_samples`). **This is a deliberate
+change of the budget's unit** (it was megabytes of ring, which made the retained history depend on the
+channel count and on relative time); it is recorded under "Deviations from the spec" in `SPECS.md`.
+
+**What a large buffer costs at startup, and what the window says while it happens.** The
+buffer's rings are value-initialised, so allocating one touches every byte of the planned rings --- about
a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread
holding the lock the frame tick also takes. It is therefore a real freeze and not a slow paint. Two
things follow. First, a rebuild whose plan describes the storage already allocated **clears** the
store instead of allocating a new one, which is what most structural changes are: startup used to ask
-for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at `--buffer-mb 1000`), and
+for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at a gigabyte of rings), and
every enable of a signal is applied in one batch rather than re-planning per signal. Second, the
window is **presented before** the buffer is planned and carries a notice naming what it is waiting
-for --- `connecting to 127.0.0.1:8000 — planning the 4000 MB buffer` --- because the remaining second
+for --- `connecting to 127.0.0.1:8000 — planning the buffer: 134 M samples per signal` --- because the remaining second
or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of
it that reads as a hang. Each allocation also names itself on stderr
(`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them
@@ -740,8 +761,12 @@ The history cost of relative mode is measured rather than computed:
| stream time (default) | **83.886 s** |
| `--relative-time` | **41.943 s** |
-Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
-Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
+Conditions: the mock at 100 kS/s, 4 channels at stride 1, the old byte budget (`--buffer-mb 200`, since removed). **That
+no longer happens**: the buffer is budgeted in samples per signal, so both modes retain the same span
+(8 388 608 ticks at the default) and relative mode instead costs a third more *memory* (32 against 24
+bytes a tick with four channels; the `planner:` dump line in `docs/claude/probe-readings.md` reads it); only a memory limit that
+bites clips it first. The paragraph below is the measurement that motivated it.
+Under the old byte budget relative mode retained **exactly half** the history — not the ~22% the original spec claimed, and
not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second
f64 ring for absolute time beside the stream clock's, so a tick's cost goes from
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
@@ -889,7 +914,7 @@ Three things worth knowing:
replayed samples age out of the ring as the stream refills it.
- **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one
per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan
- allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels
+ allocates --- and so touches --- the whole of the planned rings; enabling a comparison's eight channels
one at a time was eight of those, which is most of what made the switch feel slow. They are now
enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of
allocating a new one.
@@ -923,15 +948,15 @@ Only `.npy` is read: it is the format the item asked for and the one that carrie
export is for other tools.
**A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The
-file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten
-channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads
+file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-samples`: 20 s of ten
+channels at 500 kS/s is 10 M samples a channel, which the default 8.39 M cannot hold. The loader therefore reads
the decimation the plan chose and pushes one stored sample per group of file rows, carrying the
group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the
whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row
regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing
only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both
numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a
-larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample.
+larger `--buffer-samples` (the source's configuration carries one) to see such a file sample for sample.
## Named configurations
@@ -953,18 +978,18 @@ measurements — **and the settings that can only be decided at startup**, which
here rather than only on the command line:
```toml
-comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty"
-buffer_mb = 8
+comment = "5 kHz sine, trigger on Ch1, 256 k samples is plenty"
+buffer_samples = 262144
relative_time = false
capture_packets = 0
```
-The **buffer size** is the one that matters most: two sources on one machine rarely want the same
+The **buffer size** (samples per signal) is the one that matters most: two sources on one machine rarely want the same
ring, and the value is applied before the model exists, which is the only moment it can be — by
whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the
source was named on the command line**. A saved host's settings are read either way: opening
`udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture
-ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`,
+ring, and a flag the user actually typed wins over the file (`--buffer-samples`, `--relative-time`,
`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running
app applies everything that can change live (layout, plots, math, measurements, enables) and says
so in the status line about the rest (buffer, clock, packet capture): they take effect on the next
@@ -1004,7 +1029,11 @@ applies as it is made — there is no Apply button, and a **Reset to defaults**
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
subtitle under it where the setting needs explaining, and the control on the right. A key that is
absent means its default, so the file only mentions what changed, and a missing file is a first run
-rather than an error. The directory field shows the directory in use: the default
+rather than an error. The **Sample buffer** group holds the *Memory limit (MB)* row (`memory_limit_mb`,
+default 8192): the hard ceiling on the ring storage for the whole application, which the planner halves the
+sample budget to fit. It is the one planner input that changes while the app runs, so changing it
+re-plans the live buffer --- dropping history only if the storage actually changes --- except while a
+recording or a replay is on screen, where it is stored for the next plan. The directory field shows the directory in use: the default
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
button beside it, which writes what it found into the same field -- the field stays the one value, and
@@ -1247,11 +1276,11 @@ allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
ring per channel (N enabled signals -- see the limitation on multi-element signals
below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring
-bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
-200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
+bought nothing and cost half the memory, so the planned bytes the status line quotes are
+the whole of it. What it *would* buy is priced in the dump's `publish cost:` line — see
"There is one live buffer, not two" under the review findings.
-`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
-them with the rest of the budget. When a channel's declared sample rate is lower
+`plan_partition()` sizes the rings to the sample budget (rounded down to a power of two, halved
+until it fits the memory limit) and then decimates the shared clock until `--max-window` seconds fit. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio
(`Partition::stride`); when the requested window does not fit the budget, the shared
clock is resampled too (`Partition::time_stride`, an averaging decimation — see
@@ -1437,8 +1466,8 @@ tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads **
5.6 ms and 1.0 MB** at the cap.
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
-the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
-most as much again as the buffer does, which is the bound a long window on a fast stream reaches
+the retention is bounded by two things: the depth, and **the planned buffer** — persistence may hold at
+most as many bytes again as the rings take (`persist_budget_bytes()`), which is the bound a long window on a fast stream reaches
long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
starts with — has room for one older acquisition of each, while a row per signal has room for all
@@ -1654,7 +1683,7 @@ enabled only to feed a math channel.
several, and there was no way to see which sources the app already knew about. The chooser lists
one row per stored session: the server and port it was named after, how many plots and signals it
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
-server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
+server, port, multicast group, interface — and nothing more: `--buffer-samples` and the rest are tuning
rather than "which source", and a form that asks for them is a form you have to read before using
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
failure.
@@ -1858,7 +1887,7 @@ fixes above bound *how often* and *how much per sample*; what is left is the ari
scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one
statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those
buckets), and the window's own sample count multiplies both. Measured with the new
-`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride`
+`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-samples 100M` (so `time_stride`
stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe
— `measure cost:`'s `each` per recompute, and the tick's mean:
@@ -2001,9 +2030,9 @@ a sample and runs out on a clock. Widening only the clock keeps the cost where i
the rings are the memory budget, and the values are the overwhelming majority of them. The
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
slots, because a time slot and a value sample are now different sizes — it weighs the
-budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
-channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
-`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
+budget in **bytes** against the memory limit (`sizeof(f64)` for the shared clock plus `sizeof(f32)` per
+channel); the sample budget itself is a count of ticks, so a three-channel stream at
+`--buffer-samples 10M` retains the same 8.39 M ticks it did before. `EventSnapshot::stamps` is
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
stream time, which quantised at exactly the rate the live ring did.
@@ -2646,7 +2675,7 @@ This reverses the earlier "the app does no decimation at all" decision, and keep
reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a
full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data.
The headless check is the same aliasing case with the filter in place: at
-`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
+`--buffer-samples 65536 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly
the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost.
@@ -2912,7 +2941,7 @@ now — they are listed so they are not mistaken for anything else:
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
what a second ring would buy is a smoother frame at a latch, not recovered packets.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
- The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
+ The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-samples` is the
*budget*, and the power-of-two ring rounding means the final
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
enable/disable and calibration re-partitions (and drops the history) because the
diff --git a/SPECS.md b/SPECS.md
index 8609ae2..a4b5272 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -42,9 +42,18 @@
The following were built differently from what was asked for, each for a reason `README.md` states
in full:
-- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
- in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
- `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
+- **The buffer is budgeted in samples per signal, not megabytes** (line 1 asked for "default 200MB
+ each, configurable as cli argument"). `--buffer-samples N` (default 10M, k/M/G suffixes) is the
+ number of samples kept for every acquired signal and for the shared time ring, rounded **down** to a
+ power of two ("up to"; 10M gives 8 388 608), and a separate application setting, `--memory-limit-mb`
+ (default 8192, also in Preferences), is the hard ceiling on the ring bytes, halving the sample budget
+ when the rings would not fit. The retained history therefore no longer depends on the channel count
+ or on relative time. There is no `--buffer-mb` and no compatibility shim; a stored `buffer_mb` is
+ ignored. The "2 identical buffers" were already a single ring plus latched snapshots (see below).
+- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. *As first built* (under
+ the old byte budget; superseded by the item above, where both modes keep the same span and only
+ the memory differs) the retained history in this mode was **41.943 s** against the default
+ **83.886 s** (measured at 100 kS/s, 4 channels, `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
of two, so a third more weight costs a full halving. The design document and the implementation
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md
index 9487074..6f9f31b 100644
--- a/docs/claude/architecture.md
+++ b/docs/claude/architecture.md
@@ -133,8 +133,9 @@ thread.
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
-value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
-within the byte budget. The producer always writes it, whatever the trigger is doing —
+value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved
+until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared
+clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md
index 4e78ed1..d915040 100644
--- a/docs/claude/build-and-run.md
+++ b/docs/claude/build-and-run.md
@@ -101,8 +101,10 @@ 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
+`--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).
diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md
index aa1e1c1..1e439e3 100644
--- a/docs/claude/invariants-data-path.md
+++ b/docs/claude/invariants-data-path.md
@@ -20,7 +20,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
under a changed `base_dt` would re-date the whole ring.
**Construction is the cost.** A `RingBuffer` holds a `std::vector>`, which is
- value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per
+ value-initialised, so making a store touches every byte of the planned rings — about a second per
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
@@ -65,8 +65,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
trade and is why the status line's `rate` field is worth reading.
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
sets the target span to the recording's own length and enables every column, and the plan that
- comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is
- `--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
+ comes back may well decimate: 20 s of 10 channels at 500 kS/s is 10 M samples a channel, and the budget is
+ `--buffer-samples` (rounded down to a power of two, 8.39 M by default). The loader therefore reads `partition().time_stride` **after** the enables and pushes
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
filter, written a second time because a file does not arrive in packets. Pushing every row instead
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md
index 4540b0f..a2d6ae5 100644
--- a/docs/claude/invariants-math-channels.md
+++ b/docs/claude/invariants-math-channels.md
@@ -334,7 +334,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
as intermittent and uptime-dependent. The dump's `math '': N samples vs M time slots` is the
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
- ring has wrapped: give it a small `--buffer-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
+ ring has wrapped: give it a small `--buffer-samples`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
every edit that probe makes follows a structural change, which replans the buffer and restarts every
ring at absolute 0, where both halves of this are identities.
diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md
index 3e39764..03c0c9b 100644
--- a/docs/claude/invariants-measurements.md
+++ b/docs/claude/invariants-measurements.md
@@ -121,7 +121,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
- the user's scale -- a **15 s** window, `--buffer-mb 4000` (so `time_stride` stays 1 and the
+ the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
`recomputes`/`each`/`covered`/`buckets`/`raw`:
diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md
index 23a3727..27cc817 100644
--- a/docs/claude/invariants-session-and-export.md
+++ b/docs/claude/invariants-session-and-export.md
@@ -145,7 +145,7 @@ expression of the scheme, and the name is `sanitize()`d exactly as the host is,
with a slash cannot escape the directory.
A configuration carries the whole session *and* the settings that can only be decided before the
-first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them
+first socket: `buffer_samples`, `relative_time`, `capture_packets`. The startup chooser applies them
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
— the one moment a buffer size can be applied at all. Switching configurations inside a running
app applies what can change live and says in the status line that the rest takes effect on the
@@ -156,9 +156,9 @@ A configuration's *startup* settings reach the model from two directions, and bo
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
-one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`,
+one that never took effect. **A flag the user typed wins over the file** (`--buffer-samples`,
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
-`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default".
+`Config::buffer_samples_specified` is what tells "not asked" from "asked for the default".
The `active` file is written on every save and by the chooser's pick, so "which configuration was
I on" survives a restart. A session written before configurations existed is **imported once** as
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index c56527e..021b414 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -356,7 +356,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
- `persist_budget_bytes()`, which is the ring's own `--buffer-mb`, because persistence is a copy
+ `persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 96e35cd..bc8ce06 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -75,7 +75,7 @@ Two lines of the self-check output are assertions in disguise:
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant in
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
- show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
+ show up, so run them under `--buffer-samples 65536`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
@@ -206,10 +206,10 @@ Two lines of the self-check output are assertions in disguise:
decimation is for, and a `buffered` short of the window is the old "the control is capped
to what the ring holds" behaviour coming back. `decim` must be a power of two,
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
- under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
- decimate; at 200 MB nothing inside `--max-window` needs to.
+ under `--buffer-samples 65536`, which saturates in a few seconds and makes windows past ~6 s
+ decimate; at the default 10M nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
- Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
+ Nyquist: `--buffer-samples 65536 --freq 2000` with the probe ending on a 60 s window gives
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
@@ -763,7 +763,7 @@ Two lines of the self-check output are assertions in disguise:
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
the price of a second budget's worth of memory (README records that one live ring is why
- `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
+ the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
@@ -776,7 +776,7 @@ Two lines of the self-check output are assertions in disguise:
for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the
pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the
window's samples are the multiplier on both, so a sweep at one scale says nothing about
- another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M
+ another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M
samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe),
with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set:
* `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`,
@@ -1407,7 +1407,7 @@ popover row reads `failed: ` — one stderr line, no retry storm again
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
-[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
+[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
@@ -1422,7 +1422,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
-| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
+| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
## `[rowmenu]`
@@ -1524,8 +1524,8 @@ actually use.
```
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
-configuration's startup settings, filled in where the command line was silent. A `buffer=` here
-that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a
+configuration's startup settings, filled in where the command line was silent. A `samples=` here
+that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
@@ -2170,7 +2170,7 @@ of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-sign
**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
-~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are
+~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are
actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
@@ -2182,7 +2182,7 @@ budget bit — which is the run to take next, on the fast mock with a long windo
```
`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
-acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped`
+acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped`
non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
@@ -2374,7 +2374,7 @@ refusal it may still hold the previous text.
## `[enable-all-probe]` and switching a signal off
```
-[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer'
+[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal'
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
@@ -2383,7 +2383,7 @@ earliest thing that reads the status bar. It is set as the window is presented -
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
An empty `notice` here is the window that used to come up silent while a large buffer was allocated:
-at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank
+at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank
window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
`[udpscope] buffer allocated:` line below.)
@@ -2452,6 +2452,34 @@ rate whenever data arrives at all:
Neither can be read off the engine's own fields, which is why the store's pair is here.
+## `[udpscope] buffer:` and `[udpscope] planner:`
+
+```
+[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0
+[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1
+```
+
+Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live
+plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded
+down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings'
+real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is
+`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be
+<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M
+--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line;
+`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`).
+
+The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on
+fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's
+own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with
+`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB
+(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`.
+`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes:
+the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit
+refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped
+case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit.
+The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line
+and the status popover.
+
## `[udpscope] buffer allocated:`
```
@@ -2460,7 +2488,7 @@ Neither can be read off the engine's own fields, which is why the store's pair i
Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one
operation in the application whose duration a user can see with no instrument: the store's rings are
-`std::vector>` and so **value-initialised**, which touches the whole `--buffer-mb`
+`std::vector>` and so **value-initialised**, which touches the whole planned ring storage
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
`ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not
bookkeeping.
@@ -2469,7 +2497,7 @@ bookkeeping.
rebuild whose plan describes the storage already allocated clears the store instead
(`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
-asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those
+asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
evidence that the freeze *is* this call and not something downstream of it.
@@ -2495,8 +2523,8 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
```
-`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to
-saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
+`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to
+saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one
`Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 8a07d37..719ce17 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -46,7 +46,7 @@ when only `src/` changed, so its `-Woverlength-strings` does not reappear.
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
of buffer plan, each of which blocks the main thread for the time it takes to touch the whole
-`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for;
+planned buffer. A normal run prints two or three. Fifteen at startup is the defect it was added for;
see [probe-readings.md](probe-readings.md).
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
@@ -342,7 +342,7 @@ precedent, not an exception.
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
- a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
+ a ring that holds *more* than the window (`--buffer-samples 2M` past the window's own seconds) —
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
@@ -617,7 +617,7 @@ as distinct from the fault injection below, which exercises ingest:
`Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain,
which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural
change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long
- run**: `--buffer-mb 1` against `--rate 20000 --batch 200`, with
+ run**: `--buffer-samples 65536` against `--rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the
precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and
`y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md).
@@ -625,7 +625,7 @@ as distinct from the fault injection below, which exercises ingest:
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
round trip must carry the note **and** the startup settings —
- `comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there
+ `comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0`. A `buffer_samples=0` there
is the per-source buffer size not being written, which is the one setting a user notices only
after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe
deletes its own configuration at the end (`after forget names=…`).
@@ -681,9 +681,9 @@ as distinct from the fault injection below, which exercises ingest:
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
calibration**, and the two halves must match.
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
- entry per configuration (`host:port/name … buffer= relative= note='…'`), and
+ entry per configuration (`host:port/name … samples= relative= note='…'`), and
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
- `[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
+ `[sources] picked host:port … samples=2097152 relative=0 capture=0 config=… note='…'` — the settings the
configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
always this probe. It also prints the dialog's frame *and its own help layer*:
@@ -764,7 +764,7 @@ as distinct from the fault injection below, which exercises ingest:
python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \
a=np.zeros(n, dtype=[('time','` — then the
load line above, with no socket involved.
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
- holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
+ holding `127.0.0.1_8101/bench.toml` (`buffer_samples = 2097152`, `relative_time = true`,
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
- must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
- `relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
- default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
+ must print `[sources] settings from configuration 'bench': samples=2097152 relative=1 capture=500`, the
+ `relative time:` line, and a `span=` of about 209 s (2 097 152 ticks at 10 kS/s) — **not** the 10M default's ~839 s. The same command with
+ `--buffer-samples 262144` must read `samples=262144` and `span=26.214s`:
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
ignored whenever the address came from the command line.
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
- `[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
- `[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
+ `[sources] form: name='probe-host' note='…' samples_m=0.5 relative=1 connect_sensitive=1`, then
+ `[sources] configuration created: host:port/probe-host … samples=500000 relative=1` and a
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
- (`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
+ (`samples=500000 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
- configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
+ configuration and prints `[sources] edit form: name='…' note='…' samples_m=… relative=… button='Save'`
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
`known:` row afterwards, with the old file gone.
diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page
index 9f95d4a..bd95f8b 100644
--- a/help/C/org.udpscope.app/overview.page
+++ b/help/C/org.udpscope.app/overview.page
@@ -18,7 +18,7 @@
udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
each with up to four traces, and every row shares one time axis, so the whole figure
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
- --buffer-mb; the drawing thread reads whatever that ring holds when a frame
+ --buffer-samples; the drawing thread reads whatever that ring holds when a frame
starts, which is why a trace never makes the window stutter.
The stream is the clock. Every interval you read on screen is measured between
diff --git a/help/C/org.udpscope.app/sessions.page b/help/C/org.udpscope.app/sessions.page
index f677cac..5a37359 100644
--- a/help/C/org.udpscope.app/sessions.page
+++ b/help/C/org.udpscope.app/sessions.page
@@ -41,7 +41,7 @@
they open.
A configuration also carries the settings that can only be decided when the buffers are
- allocated: the buffer size (--buffer-mb), relative time, and the
+ allocated: the samples per signal (--buffer-samples), relative time, and the
packet capture's ring size. Opening a configuration from the chooser applies all three;
switching inside a running app applies the rest and says in the status line that these take
effect on the next start.
@@ -57,8 +57,8 @@
What a configuration persists: the time window, the rows and each row's slot
signals, the calibration, the trace colours, which signals were enabled, the math
- channels with their graphs, the measurements pane — and the startup settings: buffer
- size, relative time and the packet capture's ring size.
+ channels with their graphs, the measurements pane — and the startup settings: samples
+ per signal, relative time and the packet capture's ring size.
Signal identity is the name throughout, because a new CONFIG renumbers
the channels.
A file written by a run against a source whose streamer was down would name no
diff --git a/help/C/org.udpscope.app/source.page b/help/C/org.udpscope.app/source.page
index 956bdc4..7ed3bf8 100644
--- a/help/C/org.udpscope.app/source.page
+++ b/help/C/org.udpscope.app/source.page
@@ -28,7 +28,7 @@
stored configuration (host:port · name, its note, and the
settings it carries), newest first, plus a New host… form that takes the
address and the configuration it belongs to — its name and note, the
- buffer size, and whether time is relative or absolute. A row's pencil
+ samples per signal, and whether time is relative or absolute. A row's pencil
button opens the same form on that configuration, pre-filled, and changes to its name
or address move it (the old file is deleted).
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index 19f89b1..60f0f75 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -117,7 +117,7 @@
layout the app starts with — has room for one older acquisition of each of them; a row per
signal has room for all seven.
Memory. A retained acquisition is a whole window copied out of the ring,
- so persistence may hold at most as much again as the buffer (--buffer-mb) does.
+ so persistence may hold at most as much again as the buffer takes (see --buffer-samples and --memory-limit-mb).
On a long window at a high sample rate that bound is reached before the eighth layer.
diff --git a/help/C/org.udpscope.app/window.page b/help/C/org.udpscope.app/window.page
index 2192cff..9d2e245 100644
--- a/help/C/org.udpscope.app/window.page
+++ b/help/C/org.udpscope.app/window.page
@@ -56,11 +56,10 @@
Example
- 1 MS/s with 200 MB of buffer. A 2 s window stores every sample
- (time x1). Widen it to 600 s and the stride becomes x16: 625 S/s
- stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
- y=[-0.059, 0.079] — attenuated to about 6%, not folded down to a
- full-amplitude 125 Hz one.
+ 1 MS/s with the default buffer of 8 388 608 samples per signal (8.39 s). A 2 s window
+ stores every sample (time x1). Widen it to 600 s and the stride becomes
+ x128: 7.8 kS/s stored, anti-aliased at 3.9 kHz. A 5 kHz sine seen through that
+ window is attenuated by the filter, not folded down to a full-amplitude low-frequency one.
diff --git a/todo.md b/todo.md
index a2a464a..1069716 100644
--- a/todo.md
+++ b/todo.md
@@ -9,7 +9,7 @@
- user is acquiring a signal with 1MSps and 100_000 samples per udp frame
- last frame as number and previous in this case a 100_000 indexes must be skipped
- time is simply: `(( - ) * - ) / ` (or something like that)
-- [x] (3) ~~named configurations~~ **done**: one directory per source holding `.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_mb`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them.
+- [x] (3) ~~named configurations~~ **done**: one directory per source holding `.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_samples`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them.
- [x] (4) ~~optional udp frame storage for debugging of strange behaviours~~ **done**: `--capture-packets N`; see README and the design note in `docs/superpowers/specs/` for the ring file, the popover toggle and `tools/capture_to_pcap.py`.
- [x] (5) ~~open exported binary waveforms to view/analyse~~ **done**: `--open FILE` and the chooser's **Waveforms** page (the files opened before, newest first, plus _Open…_); the `.npy`'s dtype names the columns, so a load restores the signals, the samples and the axis the export had, and every instrument works on it — see README and `docs/claude/invariants-data-path.md`.
- [x] (6) ~~imporve right click menu on the plots~~ **done**: the plot's context menu uses the main menu's row style (`menu_button` + `.udpscope-menu`, asserted by `UDPSCOPE_PROBE_ROWMENU_MS`: uniform `label_x=26`, `h=18`, the shortcut flush right), and deleting a signal is a **Remove signal** submenu listing that plot's traces plus _All traces on this plot_.
diff --git a/tools/make-help-shots.sh b/tools/make-help-shots.sh
index e6419b6..6b49ec3 100755
--- a/tools/make-help-shots.sh
+++ b/tools/make-help-shots.sh
@@ -126,7 +126,7 @@ pick_other_window() {
start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS
local log=$1; shift
# shellcheck disable=SC2086
- env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-mb 16 ${APP_ARGS:-} >"$log" 2>&1 &
+ env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-samples 512k ${APP_ARGS:-} >"$log" 2>&1 &
app_pid=$!
}