diff --git a/tests/checks.rs b/tests/checks.rs index 0bfca6e3cd7..eee7bab86be 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -687,6 +687,11 @@ mod checks { screen_checks::self_test(); } + #[test] + fn firmware_rows_at_the_edge() -> Result<(), String> { + screen_checks::edge_self_test() + } + #[test] fn metal_audio_judges() -> Result<(), String> { audio_checks::judges_verdict() diff --git a/tests/checks/screen.rs b/tests/checks/screen.rs index 604592ea5b6..74d855a9e99 100644 --- a/tests/checks/screen.rs +++ b/tests/checks/screen.rs @@ -97,3 +97,112 @@ fn console_self_test() { console test could pass on a screen the console never touched" ); } + +/// [`Ppm::firmware_text_mode`], [`Ppm::edge_rows`] and [`TextMode::panel`] +/// against the T14's 1920x1080 panel drawn the way edk2's graphics console +/// draws it, in the two text modes firmware has chosen there: 80x50, whose +/// text starts 640 pixels in and which these lines scroll, and 240x56, whose +/// text starts at the edge and which they do not. In each, a panel that +/// stopped before the console's last lines, as the exclusive-GOP control's +/// does, must not read as carrying them. +pub fn edge_self_test() -> Result<(), String> { + let short: Vec = (0..45).map(|n| format!("Kernel memory size: {n}")).collect(); + let wide = format!("Slot A: signed header {} verifies", "0123456789abcdef".repeat(9)); + let lines: Vec<&str> = short + .iter() + .map(String::as_str) + .chain([ + wide.as_str(), + "Boot attempts: this image has had the machine 0 time(s) without reporting; now 1", + "", + " Kernel memory located past the edge", + "Loader log: the kernel handoff begins", + ]) + .collect(); + // Counted by hand: at 80 columns the lines take 45 + 3 + 2 + 1 + 1 + 1 = 53 + // rows and the first four scroll off; at 240 they take 50. + for (staged, lit) in [ + (TextMode { columns: 80, rows: 50, left: 640, top: 65 }, 41 + 3 + 1 + 1), + (TextMode { columns: 240, rows: 56, left: 0, top: 8 }, 45 + 1 + 1 + 1), + ] { + let dump = edk2_panel(1920, 1080, staged, &lines); + let mode = dump.firmware_text_mode()?; + let (carried, printed) = (dump.edge_rows(mode), mode.panel(lines.iter().copied())); + let count = |rows: &[bool]| rows.iter().filter(|&&row| row).count(); + if (mode, count(&carried), count(&printed)) != (staged, lit, lit) || carried != printed { + return Err(format!( + "a panel staged in {staged:?} with {lit} rows lit at its edge was read as {mode:?}, \ + carrying {} where the console put {}", + count(&carried), + count(&printed) + )); + } + let stopped = edk2_panel(1920, 1080, staged, &lines[..short.len()]); + let read = stopped.firmware_text_mode()?; + if read != staged || stopped.edge_rows(read) == printed { + return Err(format!( + "a panel staged in {staged:?} that stopped after {} of the console's {} lines was \ + read as {read:?}, carrying {} where the console put {}", + short.len(), + lines.len(), + count(&stopped.edge_rows(read)), + count(&printed) + )); + } + } + Ok(()) +} + +/// `lines` printed in `mode` on a cleared `width` x `height` panel, over a +/// block the size of edk2's `Logo.bmp` centred on it. Every cell a line +/// reaches is blitted whole, a glyph lighting the pixels an 8x19 capital does; +/// a line wraps once its row is full; and a line feed on the last row moves +/// the text area's pixels, the logo's with them, up a row. +fn edk2_panel(width: usize, height: usize, mode: TextMode, lines: &[&str]) -> Ppm { + let (text, logo) = ([0x98u8; 3], [0xFFu8; 3]); + let (logo_w, logo_h) = (193, 58); + let mut pixels = vec![[0u8; 3]; width * height]; + let (lx, ly) = ((width - logo_w) / 2, (height - logo_h) / 2); + for y in ly..ly + logo_h { + pixels[y * width + lx..][..logo_w].fill(logo); + } + let area = mode.columns * EFI_GLYPH_WIDTH; + let line_feed = |pixels: &mut Vec<[u8; 3]>, row: &mut usize| { + if *row + 1 < mode.rows { + *row += 1; + return; + } + for y in mode.top..mode.top + (mode.rows - 1) * EFI_GLYPH_HEIGHT { + let from = (y + EFI_GLYPH_HEIGHT) * width + mode.left; + pixels.copy_within(from..from + area, y * width + mode.left); + } + for y in mode.top + (mode.rows - 1) * EFI_GLYPH_HEIGHT..mode.top + mode.rows * EFI_GLYPH_HEIGHT { + pixels[y * width + mode.left..][..area].fill([0; 3]); + } + }; + let (mut row, mut column) = (0, 0); + for line in lines { + for c in line.chars() { + for gy in 0..EFI_GLYPH_HEIGHT { + for gx in 0..EFI_GLYPH_WIDTH { + let lit = !c.is_whitespace() && (3..=14).contains(&gy) && gx <= 6; + let (x, y) = (mode.left + column * EFI_GLYPH_WIDTH + gx, mode.top + row * EFI_GLYPH_HEIGHT + gy); + pixels[y * width + x] = if lit { text } else { [0; 3] }; + } + } + column += 1; + if column == mode.columns { + column = 0; + line_feed(&mut pixels, &mut row); + } + } + column = 0; + line_feed(&mut pixels, &mut row); + } + + let mut ppm = format!("P6\n{width} {height}\n255\n").into_bytes(); + for p in &pixels { + ppm.extend_from_slice(p); + } + Ppm::parse(&ppm) +} diff --git a/tests/common/screen.rs b/tests/common/screen.rs index 4ebfb20c044..c28874c1b3e 100644 --- a/tests/common/screen.rs +++ b/tests/common/screen.rs @@ -173,51 +173,53 @@ impl Ppm { None } - /// How many rows of text are on the panel, counted without decoding a - /// glyph: no firmware font is committed here, so a row one drew is - /// invisible to [`Ppm::text`]. - /// - /// A band taller than the pitch is a logo or two rows that touch, and - /// neither is one row of text; the pitch is the median distance between - /// band tops, because a stray scanline sets a minimum of one and drags a - /// mean as well. A panel with fewer than two bands has no pitch to take, - /// and is refused rather than counted as none. - pub fn text_row_bands(&self) -> Result { - let mut bands: Vec<(usize, usize)> = Vec::new(); - let mut top = None; - for y in 0..self.height { - let lit = (0..self.width) - .any(|x| self.pixels[y * self.width + x].iter().any(|c| *c >= FG_THRESHOLD)); - match (lit, top) { - (true, None) => top = Some(y), - (false, Some(from)) => { - bands.push((from, y)); - top = None; - } - _ => {} - } - } - if let Some(from) = top { - bands.push((from, self.height)); + /// The firmware text mode this panel's text was drawn in, found without + /// decoding a glyph: no firmware font is committed here, so a row one drew + /// is invisible to [`Ppm::text`]. Of the modes [`TextMode::offered`], it is + /// the one whose first column of cells holds the panel's leftmost lit + /// pixel, where every row of its text starts, and whose rows hold all that + /// is lit in its first [`EDGE_CELLS`] cells. + pub fn firmware_text_mode(&self) -> Result { + let leftmost = (0..self.width) + .find(|&x| (0..self.height).any(|y| self.bit(x, y))) + .ok_or("the panel has no lit pixel, so no text mode drew on it")?; + let fits: Vec = TextMode::offered(self.width, self.height) + .into_iter() + .filter(|mode| (mode.left..mode.left + EFI_GLYPH_WIDTH).contains(&leftmost)) + .filter(|mode| { + let area = mode.top..mode.top + mode.rows * EFI_GLYPH_HEIGHT; + (0..self.height).all(|y| area.contains(&y) || !self.edge_lit(*mode, y)) + }) + .collect(); + match fits[..] { + [mode] => Ok(mode), + _ => Err(format!( + "the panel's text starts at x={leftmost}, and {} of the text modes edk2 offers on \ + a {}x{} panel, not one, start there and hold it: {fits:?}", + fits.len(), + self.width, + self.height + )), } - let mut gaps: Vec = bands.windows(2).map(|pair| pair[1].0 - pair[0].0).collect(); - if gaps.is_empty() { - return Err(format!( - "the panel carries {} band(s) of lit scanlines, too few to take a row pitch from", - bands.len() - )); - } - gaps.sort_unstable(); - let pitch = gaps[gaps.len() / 2]; - let rows = bands.iter().filter(|(from, to)| to - from <= pitch).count(); - if rows == 0 { - return Err(format!( - "every one of the panel's {} band(s) is taller than the {pitch}-pixel pitch, so \ - none of them is a row of text", - bands.len() - )); - } - Ok(rows) + } + + /// Each of `mode`'s rows, `true` where a pixel of its first [`EDGE_CELLS`] + /// cells is lit. A firmware console blits a glyph's whole cell and nothing + /// outside it, and edk2 centres its boot logo as it centres `mode`, so a + /// logo narrower than 512 pixels never reaches these cells of a mode 80 or + /// more columns wide. + pub fn edge_rows(&self, mode: TextMode) -> Vec { + (0..mode.rows) + .map(|row| { + let top = mode.top + row * EFI_GLYPH_HEIGHT; + (top..top + EFI_GLYPH_HEIGHT).any(|y| self.edge_lit(mode, y)) + }) + .collect() + } + + /// Whether scanline `y` is lit in `mode`'s first [`EDGE_CELLS`] cells. + fn edge_lit(&self, mode: TextMode, y: usize) -> bool { + (mode.left..mode.left + EDGE_CELLS * EFI_GLYPH_WIDTH).any(|x| self.bit(x, y)) } /// The fill colour, read from the bottom-right pixel. The renderer paints @@ -238,6 +240,72 @@ impl Ppm { } } +/// A firmware console's glyph cell: UEFI's `EFI_GLYPH_WIDTH` by `EFI_GLYPH_HEIGHT`. +pub const EFI_GLYPH_WIDTH: usize = 8; +pub const EFI_GLYPH_HEIGHT: usize = 19; + +/// The cells at the start of a firmware console's row that [`Ppm::edge_rows`] +/// reads: every row of its text starts there, and its boot logo never reaches them. +pub const EDGE_CELLS: usize = 8; + +/// A text mode of edk2's graphics console: `columns` by `rows` cells centred on +/// the panel, the first of them `left` pixels in and `top` down. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TextMode { + pub columns: usize, + pub rows: usize, + pub left: usize, + pub top: usize, +} + +impl TextMode { + /// The text modes edk2's graphics console offers on a `width` x `height` + /// panel, each once and each centred (`InitializeGraphicsConsoleTextMode`): + /// UEFI's 80x25 and 80x50, then `mGraphicsConsoleModeData`'s, the last of + /// them the whole panel, of all these the ones that fit it. + pub fn offered(width: usize, height: usize) -> Vec { + let whole = (width / EFI_GLYPH_WIDTH, height / EFI_GLYPH_HEIGHT); + let mut modes: Vec = Vec::new(); + for (columns, rows) in [(80, 25), (80, 50), (100, 31), (128, 40), (160, 42), (240, 56), whole] { + let (w, h) = (columns * EFI_GLYPH_WIDTH, rows * EFI_GLYPH_HEIGHT); + if w > width || h > height { + continue; + } + let mode = TextMode { columns, rows, left: (width - w) >> 1, top: (height - h) >> 1 }; + if !modes.contains(&mode) { + modes.push(mode); + } + } + modes + } + + /// Each of this mode's rows once `lines` are printed onto a cleared panel, + /// `true` where one of its first [`EDGE_CELLS`] characters draws. A line + /// wraps every `columns` characters, and one that fills its last row wraps + /// before its own line feed moves the cursor again, which leaves a blank + /// row (`GraphicsConsoleConOutOutputString`); a line feed on the last row + /// scrolls the text up a row and blanks the last. + pub fn panel<'a>(self, lines: impl IntoIterator) -> Vec { + let mut panel = vec![false; self.rows]; + let mut cursor = 0; + for line in lines { + let chars: Vec = line.chars().collect(); + let blank = chars.len().is_multiple_of(self.columns).then_some(false); + let rows = chars.chunks(self.columns).map(|row| row.iter().take(EDGE_CELLS).any(|c| !c.is_whitespace())); + for row in rows.chain(blank) { + panel[cursor] = row; + if cursor + 1 == self.rows { + panel.remove(0); + panel.push(false); + } else { + cursor += 1; + } + } + } + panel + } +} + /// Cells of the console's font, in the alpha values it blits. const CELL: usize = GLYPH_W * GLYPH_H; diff --git a/tests/toyos.rs b/tests/toyos.rs index d9134cf074e..bd95116a8de 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3749,103 +3749,84 @@ fn run_screen_test( // An EXCLUSIVE open of `GraphicsOutput` calls `Stop` on the // firmware's graphics console, so with one the panel stops at the // GOP query and every later loader line is on serial alone. - let dump_at = |marker: &'static str| -> Result<(usize, String), String> { - let options = BootOptions { - profile, - qmp: true, - ready_marker: marker, - ..Default::default() - }; - metal_sim_argv_check(&qemu::profile_argv(&options))?; - let mut qemu = - QemuInstance::boot_with_options(test_config, c_bins, rust_bins, options); - let console = qemu.boot_log().to_string(); + let options = BootOptions { + profile, + qmp: true, + ready_marker: bootlog::LOADER_LAST_LINE, + ..Default::default() + }; + metal_sim_argv_check(&qemu::profile_argv(&options))?; + let mut qemu = QemuInstance::boot_with_options(test_config, c_bins, rust_bins, options); + let console = qemu.boot_log().to_string(); + + // `ClearScreen` blanks the whole panel and puts `ESC[2J` on the + // serial, so every line after the last one is on the panel too. + let Some((_, shown)) = console.rsplit_once("\x1b[2J") else { + return Err(format!( + "the console carries no clear screen, so nothing says which of its lines \ + are on the panel\n{console}" + )); + }; + let lines: Vec<&str> = shown.lines().collect(); + if !lines.iter().any(|line| line.contains(bootlog::LOADER_GOP_LINE)) { + return Err(format!("the loader never printed {:?}\n{console}", bootlog::LOADER_GOP_LINE)); + } + + // The serial line can end before the panel's own line feed has + // scrolled it, so the panel is read until two dumps in a row carry + // the same rows. The ceiling is liveness alone: past the loader's + // last line only the kernel draws, and its repaint is refused. + let map = |rows: &[bool]| rows.iter().map(|&lit| if lit { '#' } else { '.' }).collect::(); + let ceiling = Instant::now() + qemu.budget(Duration::from_secs(30)); + let mut before: Option<(screen::TextMode, Vec)> = None; + let (mode, carried) = loop { let dump = qemu.screendump(); // A row that decodes in the kernel's font is a row the kernel - // drew. Refused rather than counted: its rows are not the - // loader's and would only push the growth below green. - if dump - .rows() - .iter() - .any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) - { + // drew. Refused rather than counted: its rows are not the loader's. + if dump.rows().iter().any(|row| !row.trim().is_empty() && !row.contains(screen::UNKNOWN)) { return Err(format!( - "the kernel had already repainted the panel at {marker:?}, so these are \ - its rows and not the loader's\ndecoded screen:\n{}", + "the kernel had already repainted the panel after the loader's last \ + line, so these are its rows and not the loader's\ndecoded screen:\n{}", dump.text() )); } - Ok((dump.text_row_bands()?, console)) + let mode = dump.firmware_text_mode()?; + let now = (mode, dump.edge_rows(mode)); + match before.replace(now.clone()) { + Some(was) if was == now => break now, + Some((was, rows)) if Instant::now() >= ceiling => { + return Err(format!( + "the panel still changed between its last two dumps when the ceiling \ + passed\nbefore: {was:?} {}\nafter: {mode:?} {}\n{console}", + map(&rows), + map(&now.1) + )); + } + _ => {} + } }; - let (before, at_query) = dump_at(bootlog::LOADER_GOP_LINE)?; - let (after, console) = dump_at(bootlog::LOADER_LAST_LINE)?; - - // The growth below subtracts one boot's rows from the other's, and - // says nothing unless the two printed the same number of lines - // before the query. The lines themselves are not compared: each - // boot builds its own image, so two of them carry partition GUIDs - // drawn fresh. - let upto = |text: &str| { - text.lines().take_while(|line| !line.contains(bootlog::LOADER_GOP_LINE)).count() - }; - if upto(&at_query) != upto(&console) { - return Err(format!( - "one boot printed {} lines before the GOP query and the other {}, so their \ - row counts are not each other's baseline\n--- first\n{at_query}\n--- \ - second\n{console}", - upto(&at_query), - upto(&console) - )); - } - - // What the loader printed after the query, off its own console. - let lines: Vec<&str> = console.lines().collect(); - let at = |line: &str| { - lines - .iter() - .position(|seen| seen.contains(line)) - .ok_or_else(|| format!("the loader never printed {line:?}\n{console}")) - }; - let (query, last) = - (at(bootlog::LOADER_GOP_LINE)?, at(bootlog::LOADER_LAST_LINE)?); - if last <= query { - return Err(format!( - "the console carries {:?} at line {last} and {:?} at line {query}, so there \ - is nothing between them", - bootlog::LOADER_LAST_LINE, - bootlog::LOADER_GOP_LINE - )); - } - let printed = last - query; - // The rows those lines take on the firmware's console, whose glyph is - // eight pixels wide (UEFI 2.11 §12.9, `EFI_GLYPH_WIDTH`): a line - // wider than the mode's columns — the root bridges' descriptor dump - // is one — wraps onto a row per width it fills. - let columns = lines[query] - .split("GOP: mode ") - .nth(1) - .and_then(|mode| mode.split('x').next()) - .and_then(|width| width.parse::().ok()) - .map(|width| width / 8) - .filter(|&columns| columns > 0) - .ok_or_else(|| format!("the GOP line names no mode width: {:?}", lines[query]))?; - let rows: usize = lines[query + 1..=last].iter().map(|line| line.len().div_ceil(columns).max(1)).sum(); - - // A range and not an equality: each panel is dumped after its marker - // reached the console, so a line drawn in between is on the panel - // and not in the count. - let grew = after as i64 - before as i64; - if !(1..=rows as i64).contains(&grew) { + let printed = mode.panel(lines.iter().copied()); + let count = |rows: &[bool]| rows.iter().filter(|&&lit| lit).count(); + if carried != printed { return Err(format!( - "the panel carried {before} rows at the GOP query and {after} at the loader's \ - last line, a growth of {grew}, where the loader printed {printed} lines \ - between them, {rows} rows at {columns} columns\n{console}" + "the panel's first {} cells carry {} rows of text where the console put {} there \ + in its {}x{} text mode, row by row\npanel: {}\nconsole: {}\n{console}", + screen::EDGE_CELLS, + count(&carried), + count(&printed), + mode.columns, + mode.rows, + map(&carried), + map(&printed) )); } eprintln!( - " [screen] the panel grew {grew} row(s) across the GOP query, {before} to \ - {after}, for {printed} line(s) printed" + " [screen] the panel carries all {} rows the console put at its edge in its {}x{} \ + text mode", + count(&printed), + mode.columns, + mode.rows ); Ok(()) }