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}