2 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 b4b26d3e31 feat: a dense trace's columns are graded by dwell, so density reads as brightness
Stage 1 cured the moire by collapsing a dense gathered window onto one vertical
segment per pixel column; this is the information that collapse throws away. A
column's min and max are the same whether its samples are spread evenly or piled
at its two ends, so the flat parts of a square wave and the edges between them
drew as one ink. Style::intensity_grading, which the library leaves off and this
app turns on at the first tick (the renderer does not exist until the area is
realized), grades each column by the time the signal spent at each row -- the
digital-phosphor look.

frame path: gains a graded= field, which must equal columns=: grading is a way of
drawing the reduction, not a third path. UDPSCOPE_NO_PHOSPHOR=1 puts the style
back to the library's default and is the A/B, since a frame mean cannot attribute
its own cost -- measured on the fast mock, draw 3.24 ms graded against 2.30 ms,
with segments 1340/4157 against 131/4306 over the same lod=453 frames. That A/B
is also what found the level-floor regression now fixed upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 03:02:48 +02:00
Martino FerrariandClaude Opus 4.6 5f5b87ca60 fix: a dense trace is drawn one pixel column at a time, so it cannot moiré
At a high rate, a high frequency or on noise the plots banded visibly and the bands
crawled under a pan. Three things compound to produce it, none a bug alone: a
pyramid bucket spans one to two pixel columns, Envelope emits two points per bucket
so each contributes a near-vertical hairline and a near-vertical connector, and
every one lands at a fractional x because a trace is deliberately not snapped --
so a column's brightness beats against the pixel grid, and a pan sweeps the beat.

The cure is upstream (deps/plotter, pin moved): draw_traces reduces such a window
onto the pixel grid, one snapped vertical segment per column from that column's
lowest to its highest value. One sub-pixel phase for the whole trace, so there is
nothing left to beat, and the geometry is bounded by axes.w rather than by the
buckets.

frame path: gains a columns= field, which is what separates "the segment count
collapsed because the reduction ran" from "the window got narrower". On the fast
mock at 3 kHz over 20 s: columns=1200, segments 2041/2237 over 1201 painted frames,
against columns=0, segments 10960/216093 over 263 before -- the same run at 4.6x
the frame rate, with trace ink: clean either way.

Also corrects the documented mock invocation: --batch 20000 exceeds the datagram
limit and the mock dies with Errno 90.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 02:02:52 +02:00
5 changed files with 203 additions and 9 deletions

No files matched your search

+88 -5
View File
@@ -671,22 +671,44 @@ Two lines of the self-check output are assertions in disguise:
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
`last event ... ago` are checked there. `last event ... ago` are checked there.
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and - `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
`frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the `frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples`
— the
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw` paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw`
on any run holding a large capture**: a max in the *millions* with `lod=0` is the on any run holding a large capture**: a max in the *millions* with `lod=0` is the
empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing
every raw sample of a held event on every frame, which measured 6,000,073 segments and every raw sample of a held event on every frame, which measured 6,000,073 segments and
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit pixel, which is where `level_for()`'s own gate puts the boundary.
**`columns` must equal the frames that drew a trace at all on any run at a high rate**, and
it is the reading that says *why* `segments` is small: a dense gathered window is reduced
onto the pixel grid upstream (one snapped vertical segment per column — see the moiré
invariant below), so the geometry is bounded by `axes.w`, and a `columns=0` beside a large
`segments` is that reduction gone rather than a narrower window. Measured on
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames,
segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments
10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
level-floor invariant below gone, which measured `raw=1154 lod=0` and 6.36 ms.
**`draw`'s max must sit
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a `tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race. whole window fits in the raw path and the publish is too short to lose a race. (Do not
raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock
dies with `OSError: [Errno 90] Message too long`.)
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst - `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. `[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
@@ -1111,6 +1133,67 @@ far the largest file.
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
0 and a window index *is* the absolute one. 0 and a window index *is* the absolute one.
- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps
the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled*
under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or
noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a
bucket at one to two pixel columns, `Envelope` emits two points per bucket so each
contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per
column per trace), and every one lands at a **fractional** x because a trace is deliberately
not snapped. Under src-alpha compositing a column's brightness is then a non-linear function
of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket
pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s
`Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column
instead, so the trace has **one** phase and there is nothing left to beat. Four things to
know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the
gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw
path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction
off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole
stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one
carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is*
a pixel column.
**It needs a thin ribbon to be visible at all** — a window whose buckets span several periods
paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window
view says nothing either way. The guards are upstream's
`a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels**
in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates
nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next
column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this
path: it recomputes the **gathered** points, which the reduction is downstream of, so its
`drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone.
- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The
column above is honest about *where* the signal went and silent about *how long it was there*
— a column's min and max are identical whether its samples fill the span or pile at its two
ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead
(bright where the signal sits, faint where it only passes through, the digital-phosphor look),
and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by
an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in
the window build: `PlotArea::renderer()` is null until the area is realized, so there is no
earlier place to state a style. Three things to know here, the rest upstream in
`docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the
per-column-peak normalisation, the quantised alpha steps):
* **The finer gather level is floored at 1 whenever the flat path would have used the
pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and
`MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one
bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible)
gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat
path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look*
(fewer sub-columns than asked for, down to what the coarsest buckets give — still two or
three per column at 64 samples a bucket) and never the path. It is also why
`intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit.
* **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B
that makes either line readable against the ungraded path (see Verification).
* **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of
geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the
gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the
segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra
geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the
library's own guard, 8303 segments over a 572 px axis).
The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways
and profiles the band's outer fifths against its middle: flat reads **191/191**, graded
**191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and
`intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one
sub-bucket deep everywhere, is never dimmed out of existence.
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.** - **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
A value's precision is a property of the signal (24 bits is plenty for a sample); a A value's precision is a property of the signal (24 bits is plenty for a sample); a
timestamp's is a property of *how long the scope has been running*, because the stamps are timestamp's is a property of *how long the scope has been running*, because the stamps are
+65
View File
@@ -939,6 +939,71 @@ into the tick, the plot's own draw and the whole GTK paint, and report the segme
and whether the LOD path was taken at all. A segment count in the millions is this defect and whether the LOD path was taken at all. A segment count in the millions is this defect
returning. returning.
**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a
high sample rate, a high signal frequency or on a noisy signal the plots showed strong,
*crawling* banding — and three things compounded to produce it, none of which is a bug on its
own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `Envelope`
emits two points per bucket, so each bucket contributes a near-vertical min→max hairline
*and* a near-vertical connector to the next, about 3.6 near-vertical hairlines per column per
trace; and every one of them lands at a *fractional* x, because a trace is deliberately not
snapped. With src-alpha compositing a column's brightness is then a non-linear function of
the sub-pixel phases overlapping it, the phase pattern repeats at the beat between the bucket
pitch and the pixel pitch, and a pan sweeps the beat. It needs a *thin ribbon* to be visible:
a window whose buckets each span several periods paints a saturated block and hides it
entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a
whole-window view.
`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one
exactly vertical segment per integer column, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace
then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream
`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint
neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as*
the stroke, and the gate is the gathered density rather than the LOD level) and for why this
is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not.
It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets).
Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame
path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames**
before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames**
after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0
staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s
`columns=` field is the reading that separates "the segment count collapsed because the
reduction ran" from "the window got narrower".
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
is honest about *where* the signal went and silent about *how long it was there*: its min and
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
turns on at the first tick, since the renderer does not exist until the area is realized —
draws each column graded instead: bright where the signal sits, faint where it only passes
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
rows it spans, and normalisation is against the column's own peak. See the upstream
`docs/data-and-performance.md` for the five things that carry it and for the two that were
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
exactly where it moves fastest).
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
drawing the reduction and not a third path, so the two counts agreeing is what says the look
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
per-segment alpha is inexpressible, and each step is its own draw call.
The one performance trap here is worth stating because only the A/B found it. Asking for a
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
sub-columns than asked for — and never the path.
**The panels and the toolbar.** The header bar carries only what you reach for **The panels and the toolbar.** The header bar carries only what you reach for
*while watching a signal*: the time window, pause/resume, the trigger toggle, the *while watching a signal*: the time window, pause/resume, the trigger toggle, the
side-panel toggle and an overflow menu. Those side-panel toggle and an overflow menu. Those
+1 -1
+13
View File
@@ -35,6 +35,19 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary • Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
search over the real timestamps, never over a nominal sample grid, so a capture whose clock search over the real timestamps, never over a nominal sample grid, so a capture whose clock
wandered does not drift away from its axis as you zoom. wandered does not drift away from its axis as you zoom.
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
time</i>: each column is a single vertical line from the lowest to the highest value that falls
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
every column at slightly different sub-pixel positions, and how they overlap beats against the
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
line segments a frame instead of a couple of hundred thousand.
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
where the signal spent its time, faint where it only passed through. A column's highest and
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
the edges between them faint. It is read from a finer division of each column rather than from
the line that is drawn, and it costs about a millisecond a frame.
<b>Example</b> <b>Example</b>
+36 -3
View File
@@ -311,6 +311,9 @@ struct App {
unsigned signal_generation = ~0u; unsigned signal_generation = ~0u;
bool updating_panels = false; ///< guards signal handlers during a rebuild bool updating_panels = false; ///< guards signal handlers during a rebuild
bool relayout_pending = false; bool relayout_pending = false;
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
/// exist until the area is realized, so the one-shot has to be a tick's job.
bool style_stated = false;
bool signals_panel_initialised = false; bool signals_panel_initialised = false;
/// Set once the user edits the trigger level, so the panel stops defaulting it /// Set once the user edits the trigger level, so the panel stops defaulting it
/// into the middle of the declared range. /// into the middle of the declared range.
@@ -411,6 +414,17 @@ struct App {
std::uint64_t dbg_visible_max = 0; std::uint64_t dbg_visible_max = 0;
int dbg_raw_frames = 0; int dbg_raw_frames = 0;
int dbg_lod_frames = 0; int dbg_lod_frames = 0;
/// Frames on which at least one trace's geometry was reduced onto the pixel grid (one
/// vertical segment per column). It is orthogonal to `raw`/`lod` on purpose -- the
/// reduction is gated on the *density* of the gathered points, not on which level
/// produced them -- and it is the only reading that distinguishes "the segment count
/// collapsed because the reduction ran" from "the window got narrower".
int dbg_column_frames = 0;
/// Frames on which at least one reduced column was drawn **graded by dwell** rather than
/// as one flat band (`Style::intensity_grading`, which this app turns on). A subset of
/// `columns`: grading is a way of drawing the reduction, not a third path, so the two
/// counts being equal is what says the look is reaching every reduced frame.
int dbg_graded_frames = 0;
/// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index. /// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index.
/// This is the observable for "the plot flickers as if there were no data": `staged_segments` /// This is the observable for "the plot flickers as if there were no data": `staged_segments`
/// cannot see it, because the grid and the instruments stage geometry of their own, so a frame /// cannot see it, because the grid and the instruments stage geometry of their own, so a frame
@@ -1247,6 +1261,8 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
app->dbg_seg_last = s.staged_segments; app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames; if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames; if (s.lod_traces > 0) ++app->dbg_lod_frames;
if (s.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_frames;
if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app); if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app);
if (s.raw_traces == 0 && s.lod_traces == 0) { if (s.raw_traces == 0 && s.lod_traces == 0) {
++app->dbg_notrace_frames; ++app->dbg_notrace_frames;
@@ -4865,6 +4881,22 @@ gboolean tick(gpointer user) {
} }
} }
// The trace look, stated on the first tick that finds a renderer -- there is none until
// the area is realized, and this is the same "claim it when it exists" shape as the frame
// clock above. `intensity_grading` is off in the library by default (it is a change of
// look, not a correction, and other hosts must not be restyled by an upgrade); an
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
// than a per-frame write because `Style` is plain data and nothing else touches it.
if (!app->style_stated && app->area != nullptr) {
if (plot::Renderer* r = app->area->renderer()) {
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
// cannot attribute its own cost, so the A/B is the measurement.
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
app->style_stated = true;
}
}
if (app->relayout_pending) { if (app->relayout_pending) {
app->relayout_pending = false; app->relayout_pending = false;
relayout_areas(*app); relayout_areas(*app);
@@ -8212,9 +8244,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_draw_ms_max, app.dbg_draw_max_frame, app.dbg_draw_ms_max, app.dbg_draw_max_frame,
app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0, app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
app.dbg_paint_ms_max, app.dbg_paint_n); app.dbg_paint_ms_max, app.dbg_paint_n);
std::printf("[udpscope] frame path: raw=%d lod=%d frames, segments %llu/%u " std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
"(mean/max), visible max %llu samples\n", "%llu/%u (mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
app.dbg_graded_frames,
static_cast<unsigned long long>(app.dbg_paint_n > 0 static_cast<unsigned long long>(app.dbg_paint_n > 0
? app.dbg_seg_sum / app.dbg_paint_n ? app.dbg_seg_sum / app.dbg_paint_n
: 0), : 0),