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