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}