Skip to main content

rlx_core/render/
overlay.rs

1//! The diagnostics debug overlay (Plan 0011): a final compositing pass that,
2//! when enabled, paints a translucent panel over the scene with a frame-time
3//! sparkline, a GPU-footprint bar, a numeric fps / frame-ms / MB readout, and
4//! the analysis block (Plan 0049): the four normalized levels and the downbeat
5//! estimator's lock state.
6//!
7//! **The analysis levels are meters, not just numbers, and that is the point.**
8//! Plan 0048 Phase 6 asks whether the levels "ride the music without pumping or
9//! going numb" — a judgement about how a value *moves* against music you are
10//! hearing, made at a glance while it plays. Four digits re-rendered sixty times
11//! a second do not answer that; four bars do. The numbers stay beside them for
12//! the moments when a magnitude is what you want.
13//!
14//! Everything is drawn as solid-color quads through one instanced pipeline —
15//! the same instanced-quad pattern the scenes use — so there is no new
16//! dependency and no texture: even the digits are quads, one per lit font pixel
17//! (see `overlay_font`). The pass loads (does not clear) the scene, so
18//! it truly composites on top; when the overlay flag is off the renderer skips
19//! this pass entirely (no transparent draw), so a live show pays nothing.
20
21// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). Runs
22// every displayed frame while the overlay is enabled.
23#![deny(
24    clippy::unwrap_used,
25    clippy::expect_used,
26    clippy::indexing_slicing,
27    clippy::panic,
28    clippy::unreachable
29)]
30
31use std::fmt::Write as _;
32
33use crate::diag::{AnalysisMetrics, Metrics};
34
35use super::overlay_font::{GLYPH_H, GLYPH_W, glyph};
36use super::theme::THEME;
37use super::tier::{GridScale, Tier};
38use crate::render::gpu;
39
40/// Instance buffer capacity in quads. Comfortably covers the panel, ~240
41/// sparkline bars, the bars, and every lit font pixel of the readout — the
42/// frame-time line plus the five analysis rows come to roughly 1500 between
43/// them.
44const MAX_QUADS: usize = 4096;
45
46// Layout, in device pixels from the top-left corner.
47const MARGIN: f32 = 12.0;
48const PAD: f32 = 8.0;
49const FONT_PX: f32 = 2.0; // device pixels per font pixel
50const CHAR_ADVANCE: f32 = (GLYPH_W as f32 + 1.0) * FONT_PX;
51const TEXT_H: f32 = GLYPH_H as f32 * FONT_PX;
52const SPARK_W: f32 = 240.0; // minimum graph width; grows to fit the readout
53const SPARK_H: f32 = 72.0; // tall enough to read the frame-time trace + spikes
54const BAR_H: f32 = 12.0;
55
56/// Frame time (ms) that fills the sparkline to the top — two 60 fps frames.
57const SPARK_MAX_MS: f32 = 33.3;
58/// A comfortable 60 fps budget; frames under this read green.
59const BUDGET_MS: f32 = 16.7;
60/// Suffix on a tier the governor demoted, rather than one that was asked for.
61const DEMOTED_MARK: &str = "*";
62/// GPU bytes that fill the footprint bar (512 MiB).
63const GPU_BAR_MAX_BYTES: f32 = 512.0 * 1024.0 * 1024.0;
64
65/// Vertical pitch between the stacked analysis rows — tighter than [`PAD`], so
66/// the five read as one block instead of five separate things.
67const ROW_GAP: f32 = 5.0;
68/// Height of one analysis meter.
69const METER_H: f32 = 9.0;
70/// Characters reserved for a row's `LABEL value` column, ahead of its meter.
71/// One wider than the longest of them (`ONSET 0.18`), so every meter starts on
72/// the same x — the four levels read as one stack — with a character of gap
73/// rather than the bar butting against the last digit.
74const ROW_TEXT_CHARS: f32 = 11.0;
75
76/// The analysis rows, in draw order, each with the label the panel prints. The
77/// labels are the **only** place these words appear, so the readout-alphabet test
78/// sweeps this table rather than a copy of the strings.
79const LEVEL_LABELS: [&str; 4] = ["BASS", "MID", "TREB", "ONSET"];
80/// What the lock row says when the downbeat estimator's confidence cleared its
81/// gate, and when it did not (ADR-0050). **Two words, not a colour** — the state
82/// has to survive a screenshot and a colour-blind reader, and this is the one
83/// value Plan 0048 Phase 6 records rather than watches.
84const LOCKED_LABEL: &str = "LOCK";
85const FREE_LABEL: &str = "FREE";
86
87type Rgba = [f32; 4];
88
89/// Viewport size in device pixels, threaded through the layout helpers so a
90/// pixel rect can be converted to NDC.
91#[derive(Clone, Copy)]
92struct Vp {
93    w: f32,
94    h: f32,
95}
96
97/// The panel's colours, each a [`THEME`] role linearised for this pass's
98/// shader, which writes straight onto the `*Srgb` surface (see
99/// [`theme`](super::theme)'s colour-encoding note).
100///
101/// Resolved once per [`Overlay::build`] rather than held as constants, because
102/// the sRGB decode is not a `const fn`.
103struct Palette {
104    panel: Rgba,
105    text: Rgba,
106    spark_good: Rgba,
107    spark_warn: Rgba,
108    spark_bad: Rgba,
109    /// A bar's and a meter's empty track.
110    trough: Rgba,
111    /// The GPU-footprint bar's fill.
112    bar_fill: Rgba,
113    /// Dim reference line drawn across the sparkline at the 60 fps budget, so
114    /// the trace reads against a known mark instead of floating.
115    budget_line: Rgba,
116    /// The three band levels share a fill; `onset` gets its own because it is a
117    /// different kind of quantity — an event envelope, not a standing level —
118    /// and reading them as one stack of four identical bars invites comparing
119    /// them.
120    level_fill: Rgba,
121    onset_fill: Rgba,
122    /// The lock row, reinforcing its word rather than replacing it.
123    locked: Rgba,
124    free: Rgba,
125}
126
127impl Palette {
128    fn resolve() -> Self {
129        Self {
130            panel: THEME.panel.linear(),
131            text: THEME.text.linear(),
132            spark_good: THEME.good.linear(),
133            spark_warn: THEME.warn.linear(),
134            spark_bad: THEME.error.linear(),
135            trough: THEME.trough.linear(),
136            bar_fill: THEME.info.linear(),
137            budget_line: THEME.text_dim.alpha(0.5).linear(),
138            level_fill: THEME.info.linear(),
139            onset_fill: THEME.accent.linear(),
140            locked: THEME.good.linear(),
141            free: THEME.text_dim.linear(),
142        }
143    }
144}
145
146#[repr(C)]
147#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
148struct Quad {
149    /// NDC minimum corner (x right, y up).
150    min: [f32; 2],
151    /// NDC size (both positive).
152    size: [f32; 2],
153    color: Rgba,
154}
155
156const SHADER: &str = r#"
157struct VsOut {
158    @builtin(position) pos: vec4<f32>,
159    @location(0) color: vec4<f32>,
160}
161
162@vertex
163fn vs_main(
164    @builtin(vertex_index) vi: u32,
165    @location(0) min: vec2<f32>,
166    @location(1) size: vec2<f32>,
167    @location(2) color: vec4<f32>,
168) -> VsOut {
169    var corners = array<vec2<f32>, 6>(
170        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
171        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
172    );
173    let p = min + corners[vi] * size;
174    var out: VsOut;
175    out.pos = vec4<f32>(p, 0.0, 1.0);
176    out.color = color;
177    return out;
178}
179
180@fragment
181fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
182    return in.color;
183}
184"#;
185
186/// The overlay's instanced-quad pipeline plus reusable CPU scratch (rebuilt each
187/// frame with no steady-state allocation).
188pub struct Overlay {
189    pipeline: wgpu::RenderPipeline,
190    instances: wgpu::Buffer,
191    quads: Vec<Quad>,
192    samples: Vec<f32>,
193    text: String,
194}
195
196impl Overlay {
197    /// Build the overlay pipeline and buffers on `device`.
198    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
199        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
200            label: Some("overlay-shader"),
201            source: wgpu::ShaderSource::Wgsl(SHADER.into()),
202        });
203        let instances = device.create_buffer(&wgpu::BufferDescriptor {
204            label: Some("overlay-instances"),
205            size: (MAX_QUADS * std::mem::size_of::<Quad>()) as u64,
206            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
207            mapped_at_creation: false,
208        });
209        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
210            label: Some("overlay-pipeline-layout"),
211            bind_group_layouts: &[],
212            immediate_size: 0,
213        });
214        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
215            label: Some("overlay-pipeline"),
216            layout: Some(&pipeline_layout),
217            vertex: wgpu::VertexState {
218                module: &shader,
219                entry_point: Some("vs_main"),
220                compilation_options: Default::default(),
221                buffers: &[Some(wgpu::VertexBufferLayout {
222                    array_stride: std::mem::size_of::<Quad>() as u64,
223                    step_mode: wgpu::VertexStepMode::Instance,
224                    attributes: &wgpu::vertex_attr_array![
225                        0 => Float32x2,
226                        1 => Float32x2,
227                        2 => Float32x4,
228                    ],
229                })],
230            },
231            fragment: Some(wgpu::FragmentState {
232                module: &shader,
233                entry_point: Some("fs_main"),
234                compilation_options: Default::default(),
235                targets: &[Some(wgpu::ColorTargetState {
236                    format: surface_format,
237                    // Alpha OVER so the translucent panel shows the scene through it.
238                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
239                    write_mask: wgpu::ColorWrites::ALL,
240                })],
241            }),
242            primitive: wgpu::PrimitiveState::default(),
243            depth_stencil: None,
244            multisample: wgpu::MultisampleState::default(),
245            multiview_mask: None,
246            cache: None,
247        });
248
249        Self {
250            pipeline,
251            instances,
252            quads: Vec::with_capacity(MAX_QUADS),
253            samples: Vec::with_capacity(256),
254            text: String::with_capacity(48),
255        }
256    }
257
258    /// Composite the overlay over `view`. `frame_ms_samples` is the rolling
259    /// frame-time history (oldest first, milliseconds) for the sparkline, and
260    /// `tier` is the active quality tier, named in the readout (ADR-0045) — the
261    /// same preset looks different on different machines now, so which tier a run
262    /// resolved is diagnostics, not trivia. `demoted` marks a tier the frame-time
263    /// governor took back rather than one that was asked for, and `grid_scale`
264    /// is printed beside the tier because the same tier draws its grids at a
265    /// different fraction on a different class of adapter (ADR-0245).
266    #[allow(
267        clippy::too_many_arguments,
268        reason = "the frame's overlay inputs, each read once; bundling them would name a struct after this call site"
269    )]
270    pub fn render(
271        &mut self,
272        queue: &wgpu::Queue,
273        encoder: &mut wgpu::CommandEncoder,
274        view: &wgpu::TextureView,
275        size: (u32, u32),
276        metrics: Metrics,
277        analysis: AnalysisMetrics,
278        tier: Tier,
279        demoted: bool,
280        grid_scale: GridScale,
281        frame_ms_samples: impl Iterator<Item = f32>,
282    ) {
283        let (width, height) = size;
284        let vp = Vp {
285            w: width.max(1) as f32,
286            h: height.max(1) as f32,
287        };
288        self.samples.clear();
289        self.samples.extend(frame_ms_samples);
290        self.build(vp, metrics, analysis, tier, demoted, grid_scale);
291
292        let n = self.quads.len().min(MAX_QUADS);
293        let Some(slice) = self.quads.get(..n) else {
294            return;
295        };
296        if slice.is_empty() {
297            return;
298        }
299        queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(slice));
300
301        // Load: composite over the scene already in the surface.
302        let mut pass = gpu::color_pass(encoder, "overlay-pass", view, wgpu::LoadOp::Load);
303        pass.set_pipeline(&self.pipeline);
304        pass.set_vertex_buffer(0, self.instances.slice(..));
305        pass.draw(0..6, 0..n as u32);
306    }
307
308    /// Rebuild the quad list for this frame from the metrics + samples.
309    ///
310    /// Splitting the readout out of this method is what lets the panel's one line
311    /// of prose be tested without a GPU — see [`write_readout`].
312    fn build(
313        &mut self,
314        vp: Vp,
315        metrics: Metrics,
316        analysis: AnalysisMetrics,
317        tier: Tier,
318        demoted: bool,
319        grid_scale: GridScale,
320    ) {
321        self.quads.clear();
322        let pal = Palette::resolve();
323
324        // Build the readout first so the panel sizes to whichever is wider — the
325        // text row or the graph — and everything shares one content width.
326        write_readout(&mut self.text, metrics, tier, demoted, grid_scale);
327        // Text width, excluding the last glyph's trailing gap.
328        let text_w = (self.text.chars().count() as f32 * CHAR_ADVANCE - FONT_PX).max(0.0);
329        let content_w = text_w.max(SPARK_W);
330
331        let content_x = MARGIN + PAD;
332        let text_y = MARGIN + PAD;
333        let spark_y = text_y + TEXT_H + PAD;
334        let bar_y = spark_y + SPARK_H + PAD;
335        // The analysis block sits below the renderer's own figures: read the
336        // frame-time group as one thing, the audio group as another.
337        let analysis_y = bar_y + BAR_H + PAD;
338        let row_pitch = TEXT_H.max(METER_H) + ROW_GAP;
339        // Five rows: the four levels, then the lock state.
340        let panel_w = content_w + PAD * 2.0;
341        let panel_h = (analysis_y + row_pitch * 5.0 - ROW_GAP + PAD) - MARGIN;
342        push_rect(
343            &mut self.quads,
344            vp,
345            MARGIN,
346            MARGIN,
347            panel_w,
348            panel_h,
349            pal.panel,
350        );
351
352        draw_text(&mut self.quads, vp, content_x, text_y, &self.text, pal.text);
353
354        // Frame-time sparkline: one vertical bar per retained sample, newest at
355        // the right, colored by how close each frame ran to the 60 fps budget.
356        let count = self.samples.len();
357        if count > 0 {
358            let step = content_w / count as f32;
359            let bw = step.max(1.0);
360            for (i, &ms) in self.samples.iter().enumerate() {
361                let frac = (ms / SPARK_MAX_MS).clamp(0.0, 1.0);
362                let h = (frac * SPARK_H).max(1.0);
363                let x = content_x + i as f32 * step;
364                let color = if ms <= BUDGET_MS * 1.1 {
365                    pal.spark_good
366                } else if ms <= SPARK_MAX_MS {
367                    pal.spark_warn
368                } else {
369                    pal.spark_bad
370                };
371                // Bars grow up from the baseline (bottom of the sparkline band).
372                push_rect(&mut self.quads, vp, x, spark_y + SPARK_H - h, bw, h, color);
373            }
374        }
375        // Budget reference line across the band at the 60 fps mark, so the trace
376        // reads against a known threshold instead of floating.
377        let budget_h = (BUDGET_MS / SPARK_MAX_MS).clamp(0.0, 1.0) * SPARK_H;
378        push_rect(
379            &mut self.quads,
380            vp,
381            content_x,
382            spark_y + SPARK_H - budget_h,
383            content_w,
384            1.0,
385            pal.budget_line,
386        );
387
388        // GPU-footprint bar: dark track with a colored fill.
389        push_rect(
390            &mut self.quads,
391            vp,
392            content_x,
393            bar_y,
394            content_w,
395            BAR_H,
396            pal.trough,
397        );
398        let fill = (metrics.gpu_bytes as f32 / GPU_BAR_MAX_BYTES).clamp(0.0, 1.0);
399        if fill > 0.0 {
400            push_rect(
401                &mut self.quads,
402                vp,
403                content_x,
404                bar_y,
405                content_w * fill,
406                BAR_H,
407                pal.bar_fill,
408            );
409        }
410
411        // --- the analysis block (Plan 0049 / ADR-0052) ---
412        let meter_x = content_x + ROW_TEXT_CHARS * CHAR_ADVANCE;
413        let meter_w = (content_x + content_w - meter_x).max(0.0);
414        let levels = [
415            (analysis.bass, pal.level_fill),
416            (analysis.mid, pal.level_fill),
417            (analysis.treb, pal.level_fill),
418            (analysis.onset, pal.onset_fill),
419        ];
420        for (row, (&label, (value, fill_color))) in LEVEL_LABELS.iter().zip(levels).enumerate() {
421            let y = analysis_y + row as f32 * row_pitch;
422            write_value_row(&mut self.text, label, value);
423            draw_text(&mut self.quads, vp, content_x, y, &self.text, pal.text);
424            let meter = Meter {
425                x: meter_x,
426                y,
427                w: meter_w,
428                trough: pal.trough,
429            };
430            draw_meter(&mut self.quads, vp, meter, value, fill_color);
431        }
432
433        // The lock row. Its word carries the state and its meter carries the
434        // confidence, so a screenshot says which one it was without a legend.
435        let lock_y = analysis_y + 4.0 * row_pitch;
436        let (label, color) = if analysis.downbeat_locked {
437            (LOCKED_LABEL, pal.locked)
438        } else {
439            (FREE_LABEL, pal.free)
440        };
441        write_value_row(&mut self.text, label, analysis.downbeat_confidence);
442        draw_text(&mut self.quads, vp, content_x, lock_y, &self.text, color);
443        let meter = Meter {
444            x: meter_x,
445            y: lock_y,
446            w: meter_w,
447            trough: pal.trough,
448        };
449        draw_meter(
450            &mut self.quads,
451            vp,
452            meter,
453            analysis.downbeat_confidence,
454            color,
455        );
456    }
457}
458
459/// Where one analysis meter sits, and the track it fills.
460#[derive(Clone, Copy)]
461struct Meter {
462    x: f32,
463    /// Top of the text row the meter sits beside.
464    y: f32,
465    w: f32,
466    trough: Rgba,
467}
468
469/// One analysis meter: a dark track with a fill proportional to `value` in 0..1.
470fn draw_meter(out: &mut Vec<Quad>, vp: Vp, meter: Meter, value: f32, fill: Rgba) {
471    let Meter { x, y, w, trough } = meter;
472    // Centred against the text row so the label and its bar sit on one line.
473    let y = y + (TEXT_H - METER_H) * 0.5;
474    push_rect(out, vp, x, y, w, METER_H, trough);
475    let frac = if value.is_finite() {
476        value.clamp(0.0, 1.0)
477    } else {
478        0.0
479    };
480    push_rect(out, vp, x, y, w * frac, METER_H, fill);
481}
482
483/// Push one axis-aligned rectangle, given in top-left device-pixel coordinates,
484/// as an NDC quad. Off-screen or degenerate rects are dropped.
485fn push_rect(out: &mut Vec<Quad>, vp: Vp, x: f32, y: f32, w: f32, h: f32, color: Rgba) {
486    if w <= 0.0 || h <= 0.0 || out.len() >= MAX_QUADS {
487        return;
488    }
489    // Pixel space (y down) -> NDC (y up).
490    let x0 = x / vp.w * 2.0 - 1.0;
491    let x1 = (x + w) / vp.w * 2.0 - 1.0;
492    let y_top = 1.0 - y / vp.h * 2.0;
493    let y_bot = 1.0 - (y + h) / vp.h * 2.0;
494    out.push(Quad {
495        min: [x0, y_bot],
496        size: [x1 - x0, y_top - y_bot],
497        color,
498    });
499}
500
501/// Write the panel's single readout line into `out`, replacing its contents.
502///
503/// Unit labels and the tier name are uppercase because that is what is legible at
504/// 5x7 — and, more to the point, because the [`glyph`] table only *has* uppercase.
505/// A character with no glyph renders as a blank cell rather than failing, so the
506/// only thing standing between "the overlay names the tier" and a silent gap in
507/// the panel is the test below.
508///
509/// Takes the buffer by `&mut` rather than returning a `String`: the overlay reuses
510/// one allocation across frames, and this runs on every frame the panel is up.
511fn write_readout(
512    out: &mut String,
513    metrics: Metrics,
514    tier: Tier,
515    demoted: bool,
516    grid_scale: GridScale,
517) {
518    out.clear();
519    let _ = write!(
520        out,
521        "{:.0} FPS  {:.1} MS  {:.0} MB  {}{} {}",
522        metrics.fps,
523        metrics.frame_ms_p99,
524        metrics.gpu_bytes as f32 / (1024.0 * 1024.0),
525        tier.label(),
526        // A demoted floor and a pinned floor are the same tier and very different
527        // facts, so the marker is what keeps the demotion from being silent
528        // (ADR-0045). One glyph, on the tier it qualifies, because the panel is
529        // already the width of its sparkline.
530        if demoted { DEMOTED_MARK } else { "" },
531        // The scale last, as a bare fraction: `RICH 0.50` reads as "rich, at
532        // half", and the digits and the point are glyphs the frame-time figures
533        // already need.
534        grid_scale,
535    );
536}
537
538/// Write one analysis row — `LABEL value` — into `out`, replacing its contents.
539///
540/// The value is left-padded to a fixed width so the four level rows line up as a
541/// column, which is most of what makes them readable as a stack.
542///
543/// **The value is clamped and non-finite is printed as zero.** This is a readout,
544/// not a validator: `{:.2}` of a `NaN` is the string `NaN`, whose lowercase `a`
545/// has no glyph and would paint a blank cell (see `overlay_font`), and a level
546/// far outside 0..1 would run its number into the meter. Neither should be
547/// possible from the analyzer — that is why this clamps rather than reports.
548fn write_value_row(out: &mut String, label: &str, value: f32) {
549    out.clear();
550    let v = if value.is_finite() {
551        value.clamp(0.0, 9.99)
552    } else {
553        0.0
554    };
555    // Label padded to the widest of them so every meter starts on one x.
556    let _ = write!(out, "{label:<5} {v:.2}");
557}
558
559/// Emit the lit font pixels of `text` starting at device-pixel (`x`, `y`).
560fn draw_text(out: &mut Vec<Quad>, vp: Vp, x: f32, y: f32, text: &str, color: Rgba) {
561    for (ci, c) in text.chars().enumerate() {
562        let gx = x + ci as f32 * CHAR_ADVANCE;
563        for (row, bits) in glyph(c).iter().enumerate() {
564            for col in 0..GLYPH_W {
565                // Bit (GLYPH_W-1 - col) is column `col` from the left.
566                if (bits >> (GLYPH_W - 1 - col)) & 1 == 1 {
567                    let px = gx + col as f32 * FONT_PX;
568                    let py = y + row as f32 * FONT_PX;
569                    push_rect(out, vp, px, py, FONT_PX, FONT_PX, color);
570                }
571            }
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    //! The readout line, GPU-free. The panel geometry needs a device; the prose
579    //! does not, and the prose is what Plan 0044's done-when is about.
580
581    // Test asserts panic on failure; allowed here over the file's pragma.
582    #![allow(clippy::panic, clippy::expect_used)]
583
584    use super::{
585        DEMOTED_MARK, FREE_LABEL, GridScale, LEVEL_LABELS, LOCKED_LABEL, Tier, write_readout,
586        write_value_row,
587    };
588    use crate::diag::Metrics;
589    use crate::render::overlay_font::{GLYPH_H, glyph};
590
591    const FULL: GridScale = GridScale::FULL;
592
593    fn metrics() -> Metrics {
594        Metrics {
595            fps: 60.0,
596            frame_ms_p99: 16.7,
597            gpu_bytes: 340 * 1024 * 1024,
598            ..Metrics::default()
599        }
600    }
601
602    /// The overlay **names the active tier**, and every character it names it with
603    /// actually has a glyph.
604    ///
605    /// The second half is the load-bearing one. [`glyph`](super::glyph) returns a
606    /// blank cell for an unknown character instead of failing, so adding `FLOOR`
607    /// to the readout without adding `L`, `O` and `R` to the 5x7 table would paint
608    /// a confident `F` followed by four empty cells — a "named" tier that reads as
609    /// nothing on screen. Nothing else in the engine would notice.
610    #[test]
611    fn the_readout_names_the_tier_in_glyphs_the_font_actually_has() {
612        let metrics = metrics();
613        let mut text = String::new();
614        for tier in [Tier::Floor, Tier::Rich] {
615            write_readout(&mut text, metrics, tier, false, FULL);
616            assert!(
617                text.contains(tier.label()),
618                "the readout does not name the {} tier: {text:?}",
619                tier.as_str()
620            );
621            // The numbers stay where they were — the tier is appended, not swapped in.
622            assert!(text.starts_with("60 FPS  16.7 MS  340 MB  "), "{text:?}");
623
624            for c in tier.label().chars() {
625                assert_ne!(
626                    glyph(c),
627                    [0x00; GLYPH_H],
628                    "`{c}` of `{}` has no glyph, so the tier paints as a blank gap",
629                    tier.label()
630                );
631            }
632        }
633    }
634
635    /// **A demoted floor reads differently from a pinned floor.** They are the
636    /// same tier and very different facts — one is what the operator asked for,
637    /// the other is the engine telling them their machine could not hold the rich
638    /// budget — so if these two strings were equal the demotion would be silent,
639    /// which is exactly what ADR-0045 rules out.
640    #[test]
641    fn a_demoted_tier_is_marked_and_a_pinned_one_is_not() {
642        let (mut pinned, mut demoted) = (String::new(), String::new());
643        write_readout(&mut pinned, metrics(), Tier::Floor, false, FULL);
644        write_readout(&mut demoted, metrics(), Tier::Floor, true, FULL);
645        assert_ne!(pinned, demoted);
646        let marked = format!("{}{DEMOTED_MARK}", Tier::Floor.label());
647        assert!(demoted.contains(&marked), "{demoted:?}");
648        assert!(!pinned.contains(DEMOTED_MARK), "{pinned:?}");
649        // The mark is a suffix on the tier, not a replacement: the tier is still
650        // named, and the scale still follows it.
651        assert!(demoted.contains(Tier::Floor.label()));
652        assert!(demoted.ends_with("1.00"), "{demoted:?}");
653    }
654
655    /// **The readout names the grid scale beside the tier** (ADR-0245), in
656    /// glyphs the font has. The scale is what makes one tier two different
657    /// pictures on two classes of adapter, so a screenshot of the panel that
658    /// named the tier and not the scale would leave the question it exists to
659    /// answer open.
660    #[test]
661    fn the_readout_names_the_grid_scale_beside_the_tier() {
662        let mut text = String::new();
663        for (value, printed) in [(1.0, "1.00"), (0.75, "0.75"), (0.5, "0.50"), (0.25, "0.25")] {
664            let scale = GridScale::new(value).expect("in range");
665            write_readout(&mut text, metrics(), Tier::Rich, false, scale);
666            assert!(
667                text.ends_with(&format!("{} {printed}", Tier::Rich.label())),
668                "{text:?}"
669            );
670            for c in printed.chars() {
671                assert_ne!(glyph(c), [0x00; GLYPH_H], "`{c}` of the scale has no glyph");
672            }
673        }
674    }
675
676    /// **Every character the readout can emit has a glyph.**
677    ///
678    /// This is the guard the whole analysis block rests on. `glyph` returns a
679    /// blank cell for an uncovered character rather than failing, so `BASS` in a
680    /// font without `A` paints `B SS` and nothing in the engine notices — no
681    /// error, no warning, no failing test. Plan 0044 hit the same trap with the
682    /// tier names.
683    ///
684    /// So this sweeps the readout's **alphabet**, not a fixed expected string: it
685    /// drives every writer the panel has over a range of inputs chosen to reach
686    /// every digit, both lock words, all four level labels, both tiers and the
687    /// demotion mark, and asserts each emitted non-space character is lit. A
688    /// changed format string stays covered; a new label does not sneak past.
689    #[test]
690    fn every_character_the_readout_can_emit_has_a_glyph() {
691        let mut text = String::new();
692        let mut seen = std::collections::BTreeSet::new();
693        let mut sweep = |text: &String| {
694            for c in text.chars() {
695                seen.insert(c);
696                if c == ' ' {
697                    continue;
698                }
699                assert_ne!(
700                    glyph(c),
701                    [0x00; GLYPH_H],
702                    "`{c}` has no glyph, so the readout `{text}` paints a blank cell there"
703                );
704            }
705        };
706
707        // The frame-time line, over values that between them print every digit,
708        // both tiers, and the demotion mark.
709        for (fps, p99, bytes) in [
710            (60.0, 16.7, 340 * 1024 * 1024),
711            (23.0, 45.9, 178 * 1024 * 1024),
712            (0.0, 0.0, 0),
713        ] {
714            for tier in [Tier::Floor, Tier::Rich] {
715                for demoted in [false, true] {
716                    for scale in [0.25, 0.5, 1.0] {
717                        write_readout(
718                            &mut text,
719                            Metrics {
720                                fps,
721                                frame_ms_p99: p99,
722                                gpu_bytes: bytes,
723                                ..Metrics::default()
724                            },
725                            tier,
726                            demoted,
727                            GridScale::new(scale).expect("in range"),
728                        );
729                        sweep(&text);
730                    }
731                }
732            }
733        }
734
735        // Every analysis row: all four level labels and both lock words, over
736        // values that reach every digit — plus the ones that must never reach the
737        // panel as text at all (non-finite, out of range, negative).
738        let labels: Vec<&str> = LEVEL_LABELS
739            .iter()
740            .copied()
741            .chain([LOCKED_LABEL, FREE_LABEL])
742            .collect();
743        for label in labels {
744            for value in [
745                0.0,
746                0.123,
747                0.456,
748                0.789,
749                1.0,
750                -0.5,
751                42.0,
752                f32::NAN,
753                f32::INFINITY,
754                f32::NEG_INFINITY,
755            ] {
756                write_value_row(&mut text, label, value);
757                sweep(&text);
758            }
759        }
760
761        // And the sweep must actually have covered letters — a writer that
762        // silently produced empty strings would satisfy every assertion above.
763        assert!(
764            seen.iter().filter(|c| c.is_ascii_uppercase()).count() >= 12,
765            "the sweep saw too few letters to be exercising the labels: {seen:?}"
766        );
767        assert!(
768            ('0'..='9').all(|d| seen.contains(&d)),
769            "the sweep never printed some digit: {seen:?}"
770        );
771    }
772
773    /// The lock state survives without colour. `LOCK` and `FREE` are the same
774    /// width and differ in every character, so a screenshot — or a colour-blind
775    /// reader — sees the estimator's gate rather than inferring it from a hue.
776    /// This is the value Plan 0048 Phase 6 records a **rate** from, and ADR-0050's
777    /// stopping condition is unfalsifiable without it.
778    #[test]
779    fn the_lock_row_states_the_gate_in_words() {
780        let (mut locked, mut free) = (String::new(), String::new());
781        write_value_row(&mut locked, LOCKED_LABEL, 0.83);
782        write_value_row(&mut free, FREE_LABEL, 0.21);
783        assert_ne!(locked, free);
784        assert!(locked.starts_with(LOCKED_LABEL), "{locked:?}");
785        assert!(free.starts_with(FREE_LABEL), "{free:?}");
786        // The confidence rides along, so the row says how close a free frame was.
787        assert!(locked.ends_with("0.83"), "{locked:?}");
788        assert!(free.ends_with("0.21"), "{free:?}");
789        // Same width, so the two states do not shift the column under them.
790        assert_eq!(locked.chars().count(), free.chars().count());
791    }
792
793    /// The four level rows are a column: same label field width, so their values
794    /// and meters line up. A ragged stack is the difference between reading four
795    /// bars at a glance and parsing four lines.
796    #[test]
797    fn the_level_rows_line_up_as_a_column() {
798        let mut text = String::new();
799        let mut widths = std::collections::BTreeSet::new();
800        for label in LEVEL_LABELS {
801            write_value_row(&mut text, label, 0.5);
802            widths.insert(text.chars().count());
803            assert!(text.starts_with(label), "{text:?}");
804        }
805        assert_eq!(widths.len(), 1, "rows are ragged: {widths:?}");
806    }
807
808    /// A space is legitimately blank, so the check above would pass vacuously if
809    /// the tier label were ever spaces — and it would also pass if `glyph` had
810    /// stopped returning blanks for unknown characters, which is what makes the
811    /// missing-glyph assertion a real check. Pin both.
812    #[test]
813    fn an_unknown_character_is_blank_and_a_known_one_is_not() {
814        assert_eq!(glyph('\u{0}').len(), GLYPH_H);
815        assert_eq!(glyph('~'), [0x00; GLYPH_H], "unknown must render blank");
816        assert_ne!(glyph('F'), [0x00; GLYPH_H], "a covered glyph must be lit");
817        for c in DEMOTED_MARK.chars() {
818            assert_ne!(glyph(c), [0x00; GLYPH_H], "the demotion mark must be lit");
819        }
820        for tier in [Tier::Floor, Tier::Rich] {
821            assert!(!tier.label().trim().is_empty());
822        }
823    }
824}