diff --git a/CLAUDE.md b/CLAUDE.md index 07cba98..ed8e759 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -671,7 +671,7 @@ 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 `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 path: raw=R lod=L columns=C frames, segments S/T (mean/max), visible max V samples` + `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 clock's `before-paint` to the end of the host's `render` handler) and the whole GTK @@ -689,6 +689,16 @@ Two lines of the self-check output are assertions in disguise: `--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 late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is @@ -1151,6 +1161,39 @@ far the largest file. 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.** 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 diff --git a/README.md b/README.md index cde4f72..9113e59 100644 --- a/README.md +++ b/README.md @@ -971,6 +971,39 @@ 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 *while watching a signal*: the time window, pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those diff --git a/deps/plotter b/deps/plotter index e81e846..cc908ef 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit e81e84645f57a364ceb8469dc4989d8df9a41693 +Subproject commit cc908efa36279595e344d317b460973de5e95785 diff --git a/src/help/06-navigate.txt b/src/help/06-navigate.txt index 68f41cd..bf5147e 100644 --- a/src/help/06-navigate.txt +++ b/src/help/06-navigate.txt @@ -42,6 +42,12 @@ The full table is in the shortcut overview (Ctrl+/). pixel grid — which reads as bands of light and dark that crawl 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 graded by brightness, 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. Example diff --git a/src/ui.cpp b/src/ui.cpp index d710504..27645df 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -311,6 +311,9 @@ struct App { unsigned signal_generation = ~0u; bool updating_panels = false; ///< guards signal handlers during a rebuild 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; /// Set once the user edits the trigger level, so the panel stops defaulting it /// into the middle of the declared range. @@ -417,6 +420,11 @@ struct App { /// 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. /// 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 @@ -1254,6 +1262,7 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { if (s.raw_traces > 0) ++app->dbg_raw_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) { ++app->dbg_notrace_frames; @@ -4872,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) { app->relayout_pending = false; relayout_areas(*app); @@ -8219,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_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0, app.dbg_paint_ms_max, app.dbg_paint_n); - std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d frames, segments %llu/%u " - "(mean/max), visible max %llu samples\n", + std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments " + "%llu/%u (mean/max), visible max %llu samples\n", app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames, + app.dbg_graded_frames, static_cast(app.dbg_paint_n > 0 ? app.dbg_seg_sum / app.dbg_paint_n : 0),